Skip to main content

asciidoc_parser/document/
catalog.rs

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