Skip to main content

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
29impl Default for Catalog {
30    fn default() -> Self {
31        Self::new()
32    }
33}
34
35impl Catalog {
36    pub(crate) fn new() -> Self {
37        Self {
38            refs: HashMap::new(),
39            reftext_to_id: HashMap::new(),
40            footnotes: Vec::new(),
41        }
42    }
43
44    /// Register a new referenceable element in the catalog.
45    ///
46    /// # Arguments
47    /// * `id` - The unique identifier for the element
48    /// * `reftext` - Optional reference text for the element
49    /// * `ref_type` - Type of referenceable element
50    ///
51    /// # Returns
52    /// * `Ok(())` if the element was successfully registered
53    /// * `Err(DuplicateIdError)` if the ID is already in use
54    pub(crate) fn register_ref(
55        &mut self,
56        id: &str,
57        reftext: Option<&str>,
58        ref_type: RefType,
59    ) -> Result<(), DuplicateIdError> {
60        if self.refs.contains_key(id) {
61            return Err(DuplicateIdError(id.to_string()));
62        }
63
64        let entry = RefEntry {
65            id: id.to_string(),
66            reftext: reftext.map(|s| s.to_owned()),
67            ref_type,
68            signifier: None,
69        };
70
71        self.refs.insert(id.to_string(), entry);
72
73        if let Some(reftext) = reftext {
74            self.reftext_to_id
75                .entry(reftext.to_string())
76                .or_insert_with(|| id.to_string());
77        }
78
79        Ok(())
80    }
81
82    /// Generate a unique ID based on a base ID and register it in the catalog.
83    ///
84    /// If the base ID is not in use, it is returned as-is. Otherwise, numeric
85    /// suffixes are appended until a unique ID is found. The generated ID is
86    /// then registered in the catalog with the provided parameters.
87    ///
88    /// # Arguments
89    /// * `base_id` - The base identifier to use
90    /// * `reftext` - Optional reference text for the element
91    /// * `ref_type` - Type of referenceable element
92    ///
93    /// # Returns
94    /// The unique ID that was generated and registered.
95    pub(crate) fn generate_and_register_unique_id(
96        &mut self,
97        base_id: &str,
98        reftext: Option<&str>,
99        ref_type: RefType,
100    ) -> String {
101        let unique_id = if !self.contains_id(base_id) {
102            base_id.to_string()
103        } else {
104            let mut counter = 2;
105            loop {
106                let candidate = format!("{}-{}", base_id, counter);
107                if !self.contains_id(&candidate) {
108                    break candidate;
109                }
110                counter += 1;
111            }
112        };
113
114        // Register the generated unique ID.
115        let entry = RefEntry {
116            id: unique_id.clone(),
117            reftext: reftext.map(|s| s.to_owned()),
118            ref_type,
119            signifier: None,
120        };
121
122        self.refs.insert(unique_id.clone(), entry);
123
124        if let Some(reftext) = reftext {
125            self.reftext_to_id
126                .entry(reftext.to_string())
127                .or_insert_with(|| unique_id.clone());
128        }
129
130        unique_id
131    }
132
133    /// Returns a reference entry by ID, if it exists.
134    pub fn get_ref(&self, id: &str) -> Option<&RefEntry> {
135        self.refs.get(id)
136    }
137
138    /// Returns `true` if an ID is already registered in the catalog.
139    pub fn contains_id(&self, id: &str) -> bool {
140        self.refs.contains_key(id)
141    }
142
143    /// Resolve reference text to an ID, if possible.
144    pub fn resolve_id(&self, reftext: &str) -> Option<String> {
145        self.reftext_to_id.get(reftext).cloned()
146    }
147
148    /// Attaches an [`XrefSignifier`] to an already-registered element, so a
149    /// cross-reference to it can build `full`/`short`
150    /// [`xrefstyle`](crate::parser::XrefStyle) text. A no-op if `id` is not
151    /// registered.
152    pub(crate) fn set_signifier(&mut self, id: &str, signifier: XrefSignifier) {
153        if let Some(entry) = self.refs.get_mut(id) {
154            entry.signifier = Some(signifier);
155        }
156    }
157
158    /// Returns an iterator over all registered reference IDs, in an
159    /// unspecified order.
160    ///
161    /// This lets a multi-document pipeline enumerate a document's anchors and
162    /// section IDs (for example, to build a global cross-reference index)
163    /// without re-walking the block tree.
164    pub fn ids(&self) -> impl Iterator<Item = &str> {
165        self.refs.keys().map(String::as_str)
166    }
167
168    /// Returns an iterator over all registered reference entries, in an
169    /// unspecified order.
170    ///
171    /// Each item pairs an ID with its [`RefEntry`] (which also carries the
172    /// entry's reftext and [`RefType`]).
173    pub fn entries(&self) -> impl Iterator<Item = (&str, &RefEntry)> {
174        self.refs.iter().map(|(id, entry)| (id.as_str(), entry))
175    }
176
177    /// Returns the number of registered references.
178    pub fn len(&self) -> usize {
179        self.refs.len()
180    }
181
182    /// Returns `true` if the catalog contains no registered references.
183    pub fn is_empty(&self) -> bool {
184        self.refs.is_empty()
185    }
186
187    /// Returns the footnotes registered in this document, in document order.
188    pub fn footnotes(&self) -> &[Footnote] {
189        &self.footnotes
190    }
191
192    /// Registers a newly-defined [`Footnote`].
193    pub(crate) fn register_footnote(&mut self, footnote: Footnote) {
194        self.footnotes.push(footnote);
195    }
196
197    /// Returns the registered footnote with the given ID, if one exists.
198    pub(crate) fn footnote_with_id(&self, id: &str) -> Option<&Footnote> {
199        self.footnotes.iter().find(|f| f.id.as_deref() == Some(id))
200    }
201
202    /// Removes and returns the current footnote list, leaving an empty list
203    /// behind. Used to give a nested document (an AsciiDoc table cell) its own
204    /// footnote registry so its footnotes are not shared with the enclosing
205    /// document.
206    pub(crate) fn take_footnotes(&mut self) -> Vec<Footnote> {
207        std::mem::take(&mut self.footnotes)
208    }
209
210    /// Restores a previously-[taken](Self::take_footnotes) footnote list,
211    /// discarding any footnotes registered in the meantime.
212    pub(crate) fn restore_footnotes(&mut self, footnotes: Vec<Footnote>) {
213        self.footnotes = footnotes;
214    }
215}
216
217/// A footnote registered while substituting the inline `footnote:[…]` macro.
218///
219/// A footnote is defined at the location of its reference, but its text is
220/// extracted to an item in the document's footnote list. The same footnote can
221/// be referenced from multiple locations by assigning it an ID at the first
222/// occurrence and repeating that ID (with empty text) afterward; only the
223/// defining occurrence produces a `Footnote` entry.
224#[derive(Clone, Eq, PartialEq)]
225pub struct Footnote {
226    /// The footnote's number, assigned in document order via the
227    /// `footnote-number` counter. Normally a consecutive integer (`1`, `2`, …),
228    /// but stored as a string because the counter honors any seed the document
229    /// sets (e.g. `:footnote-number: z` yields `aa`, `ab`, … as Asciidoctor
230    /// does).
231    pub index: String,
232
233    /// The optional ID assigned to this footnote (the target of the macro, e.g.
234    /// `disclaimer` in `footnote:disclaimer[…]`). `None` for an anonymous
235    /// footnote.
236    pub id: Option<String>,
237
238    /// The already-substituted text of the footnote. When the footnote contains
239    /// cross-references, this reflects the unresolved fallback rendering until
240    /// the document's references are resolved, after which it reflects the
241    /// resolved links; it is always clean, user-facing text.
242    pub text: String,
243
244    /// Deferred cross-references discovered in the footnote text, awaiting
245    /// resolution. `None` for the common case of a footnote with no
246    /// cross-references.
247    pub(crate) deferred: Option<Box<FootnoteDeferred>>,
248}
249
250impl Footnote {
251    /// Resolves any cross-references embedded in this footnote's text using
252    /// `resolver`, then rebuilds [`text`](Self::text) from the resolved state.
253    /// Any unresolved target is reported in `warnings`.
254    ///
255    /// A footnote with no cross-references is left untouched.
256    pub(crate) fn resolve_references(
257        &mut self,
258        resolver: &dyn crate::parser::ReferenceResolver,
259        renderer: &dyn crate::parser::InlineSubstitutionRenderer,
260        warnings: &mut Vec<crate::parser::ReferenceWarning>,
261    ) {
262        if let Some(deferred) = self.deferred.as_mut() {
263            deferred.resolve(resolver, warnings);
264            self.text = deferred.render(renderer);
265        }
266    }
267}
268
269impl std::fmt::Debug for Footnote {
270    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
271        // The deferred cross-reference state is an internal implementation
272        // detail, omitted unless present so that the (very common)
273        // cross-reference-free footnote debugs as a plain field set.
274        let mut s = f.debug_struct("Footnote");
275        s.field("index", &self.index);
276        s.field("id", &self.id);
277        s.field("text", &self.text);
278
279        if let Some(deferred) = self.deferred.as_ref() {
280            s.field("deferred", deferred);
281        }
282
283        s.finish()
284    }
285}
286
287impl std::fmt::Debug for Catalog {
288    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
289        f.debug_struct("Catalog")
290            .field("refs", &DebugHashMapFrom(&self.refs))
291            .field("reftext_to_id", &DebugHashMapFrom(&self.reftext_to_id))
292            .field("footnotes", &self.footnotes)
293            .finish()
294    }
295}
296/// Type of referenceable element in the document.
297#[derive(Clone, PartialEq, Eq)]
298pub enum RefType {
299    /// Standard anchor element (`[[id]]` or `[[id,reftext]]`).
300    Anchor,
301
302    /// Section heading that can be referenced.
303    Section,
304
305    /// Bibliography reference (`[[[id]]]` or `[[[id,reftext]]]`).
306    Bibliography,
307}
308
309impl std::fmt::Debug for RefType {
310    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
311        match self {
312            Self::Anchor => f.write_str("RefType::Anchor"),
313            Self::Section => f.write_str("RefType::Section"),
314            Self::Bibliography => f.write_str("RefType::Bibliography"),
315        }
316    }
317}
318
319/// Entry in the document catalog representing a referenceable element.
320#[derive(Clone, Debug, Eq, PartialEq)]
321pub struct RefEntry {
322    /// The unique identifier for this element.
323    pub id: String,
324
325    /// Reference text for this element (explicit or computed).
326    pub reftext: Option<String>,
327
328    /// Type of referenceable element.
329    pub ref_type: RefType,
330
331    /// The signifier and number used to build `full`/`short`
332    /// [`xrefstyle`](crate::parser::XrefStyle) cross-reference text for this
333    /// target. Present only for a numbered section or captioned block that has
334    /// no explicit reftext; `None` for every other element (plain anchors,
335    /// bibliography entries, unnumbered sections, and targets carrying an
336    /// explicit reftext, for which `xrefstyle` formatting does not apply).
337    pub signifier: Option<XrefSignifier>,
338}
339
340/// Error that occurs when attempting to register a duplicate ID.
341#[derive(Clone, Debug, Eq, PartialEq)]
342pub(crate) struct DuplicateIdError(pub(crate) String);
343
344impl std::fmt::Display for DuplicateIdError {
345    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
346        write!(f, "ID '{}' already registered", self.0)
347    }
348}
349
350impl std::error::Error for DuplicateIdError {}
351
352#[cfg(test)]
353mod tests {
354    #![allow(clippy::unwrap_used)]
355
356    use super::*;
357
358    #[test]
359    fn new_catalog_is_empty() {
360        let catalog = Catalog::new();
361        assert!(catalog.is_empty());
362        assert_eq!(catalog.len(), 0);
363    }
364
365    #[test]
366    fn register_ref_success() {
367        let mut catalog = Catalog::new();
368
369        let result = catalog.register_ref("test-id", Some("Test Reference"), RefType::Anchor);
370
371        assert!(result.is_ok());
372        assert_eq!(catalog.len(), 1);
373        assert!(catalog.contains_id("test-id"));
374    }
375
376    #[test]
377    fn register_duplicate_id_fails() {
378        let mut catalog = Catalog::new();
379
380        // Register first reference.
381        catalog
382            .register_ref("test-id", Some("First"), RefType::Anchor)
383            .unwrap();
384
385        // Attempt to register duplicate.
386        let result = catalog.register_ref("test-id", Some("Second"), RefType::Section);
387
388        let error = result.unwrap_err();
389        assert_eq!(error.0, "test-id");
390    }
391
392    #[test]
393    fn generate_and_register_unique_id() {
394        let mut catalog = Catalog::new();
395
396        // Test with available ID.
397        let id1 = catalog.generate_and_register_unique_id(
398            "available",
399            Some("Available Ref"),
400            RefType::Anchor,
401        );
402        assert_eq!(id1, "available");
403        assert!(catalog.contains_id("available"));
404        assert_eq!(
405            catalog.resolve_id("Available Ref"),
406            Some("available".to_string())
407        );
408
409        // Test with taken IDs.
410        catalog
411            .register_ref("taken", None, RefType::Anchor)
412            .unwrap();
413        catalog
414            .register_ref("taken-2", None, RefType::Anchor)
415            .unwrap();
416
417        let id2 = catalog.generate_and_register_unique_id("taken", None, RefType::Section);
418        assert_eq!(id2, "taken-3");
419        assert!(catalog.contains_id("taken-3"));
420    }
421
422    #[test]
423    fn get_ref() {
424        let mut catalog = Catalog::new();
425
426        catalog
427            .register_ref("test-id", Some("Test Reference"), RefType::Bibliography)
428            .unwrap();
429
430        let entry = catalog.get_ref("test-id").unwrap();
431        assert_eq!(entry.id, "test-id");
432        assert_eq!(entry.reftext, Some("Test Reference".to_string()));
433        assert_eq!(entry.ref_type, RefType::Bibliography);
434
435        assert!(catalog.get_ref("nonexistent").is_none());
436    }
437
438    #[test]
439    fn enumerate_ids_and_entries() {
440        let mut catalog = Catalog::new();
441
442        catalog
443            .register_ref("intro", Some("Introduction"), RefType::Section)
444            .unwrap();
445        catalog
446            .register_ref("fig-1", None, RefType::Anchor)
447            .unwrap();
448
449        // `ids()` enumerates every registered ID (order is unspecified).
450        let mut ids: Vec<&str> = catalog.ids().collect();
451        ids.sort_unstable();
452        assert_eq!(ids, vec!["fig-1", "intro"]);
453
454        // `entries()` pairs each ID with its full entry.
455        let entries: Vec<(&str, &RefEntry)> = catalog.entries().collect();
456        assert_eq!(entries.len(), 2);
457
458        let (fig_id, fig_entry) = entries.iter().find(|(id, _)| *id == "fig-1").unwrap();
459        assert_eq!(*fig_id, "fig-1");
460        assert_eq!(fig_entry.id, "fig-1");
461        assert_eq!(fig_entry.reftext, None);
462        assert_eq!(fig_entry.ref_type, RefType::Anchor);
463
464        let (_, intro_entry) = entries.iter().find(|(id, _)| *id == "intro").unwrap();
465        assert_eq!(intro_entry.reftext, Some("Introduction".to_string()));
466        assert_eq!(intro_entry.ref_type, RefType::Section);
467    }
468
469    #[test]
470    fn resolve_id() {
471        let mut catalog = Catalog::new();
472
473        catalog
474            .register_ref("anchor1", Some("Reference Text"), RefType::Anchor)
475            .unwrap();
476
477        catalog
478            .register_ref("anchor2", Some("Another Reference"), RefType::Section)
479            .unwrap();
480
481        assert_eq!(
482            catalog.resolve_id("Reference Text"),
483            Some("anchor1".to_string())
484        );
485        assert_eq!(
486            catalog.resolve_id("Another Reference"),
487            Some("anchor2".to_string())
488        );
489        assert_eq!(catalog.resolve_id("Nonexistent"), None);
490    }
491
492    #[test]
493    fn resolve_id_first_wins_on_duplicates() {
494        let mut catalog = Catalog::new();
495
496        // Register two different IDs with same reftext.
497        catalog
498            .register_ref("first", Some("Same Text"), RefType::Anchor)
499            .unwrap();
500
501        catalog
502            .register_ref("second", Some("Same Text"), RefType::Section)
503            .unwrap();
504
505        assert_eq!(catalog.resolve_id("Same Text"), Some("first".to_string()));
506    }
507
508    #[test]
509    fn duplicate_id_error_impl_display() {
510        let did_error = DuplicateIdError("foo".to_string());
511        assert_eq!(did_error.to_string(), "ID 'foo' already registered");
512    }
513
514    #[test]
515    fn ref_type_impl_debug() {
516        assert_eq!(format!("{:#?}", RefType::Anchor), "RefType::Anchor");
517        assert_eq!(format!("{:#?}", RefType::Section), "RefType::Section");
518
519        assert_eq!(
520            format!("{:#?}", RefType::Bibliography),
521            "RefType::Bibliography"
522        );
523    }
524}