Skip to main content

asciidoc_parser/parser/
reference_resolver.rs

1//! Cross-reference resolution.
2//!
3//! Parsing leaves cross-references (`<<id>>`, `xref:id[…]`) unresolved so they
4//! can be resolved later, once the full document — or, for multi-document
5//! workflows such as Antora, the full corpus — has been parsed and its catalog
6//! of referenceable elements is complete.
7//!
8//! Resolution is performed through the [`ReferenceResolver`] trait. This crate
9//! ships [`CatalogResolver`], a single-document resolver backed by one
10//! [`Catalog`]. A host that resolves references across many documents supplies
11//! its own implementation (binding the "from" document when it constructs the
12//! resolver), and this crate makes no attempt to merge catalogs.
13
14use crate::document::Catalog;
15
16/// The resolved destination of a cross-reference.
17#[derive(Clone, Debug, Eq, PartialEq)]
18pub struct ResolvedReference {
19    /// The hyperlink destination. For a same-document reference this is a
20    /// fragment such as `#section-id`; a cross-document resolver may return a
21    /// full or relative URL.
22    pub href: String,
23
24    /// The display text to use when the cross-reference did not specify its own
25    /// text. This is typically the target's reference text (reftext).
26    pub text: Option<String>,
27}
28
29/// A warning produced while resolving cross-references.
30#[derive(Clone, Debug, Eq, PartialEq)]
31pub struct ReferenceWarning {
32    /// The cross-reference target that could not be resolved, exactly as
33    /// written in the source.
34    pub target: String,
35
36    /// The kind of problem encountered.
37    pub kind: ReferenceWarningKind,
38}
39
40/// The kind of problem described by a [`ReferenceWarning`].
41#[derive(Clone, Copy, Debug, Eq, PartialEq)]
42#[non_exhaustive]
43pub enum ReferenceWarningKind {
44    /// The target could not be resolved to any destination.
45    Unresolved,
46}
47
48/// Describes a single cross-reference that needs to be resolved.
49///
50/// This carries only information the crate itself knows about the reference. A
51/// multi-document host that needs to know which document a reference originates
52/// from binds that "from" context when it constructs its [`ReferenceResolver`],
53/// rather than receiving it here — keeping this seam free of any host-specific
54/// coordinate system.
55#[non_exhaustive]
56pub struct ResolutionContext<'a> {
57    /// The raw, uninterpreted cross-reference target, exactly as written in the
58    /// source (e.g. `"section-id"`, a reftext, or `"other-page.adoc#frag"`).
59    pub target: &'a str,
60
61    /// Explicit link text supplied in the cross-reference, if any.
62    pub provided_text: Option<&'a str>,
63}
64
65/// Resolves cross-reference targets to their destinations.
66///
67/// Implementations map a [`ResolutionContext`] to a [`ResolvedReference`], or
68/// return `None` when the target cannot be resolved (the caller then renders an
69/// unresolved-reference fallback and may emit a warning).
70pub trait ReferenceResolver {
71    /// Resolve a single cross-reference.
72    fn resolve(&self, context: &ResolutionContext<'_>) -> Option<ResolvedReference>;
73}
74
75/// The default single-document [`ReferenceResolver`], backed by one
76/// [`Catalog`].
77///
78/// It resolves bare IDs and natural cross-references (by reference text) to
79/// `#id` fragments. Targets that carry a path component (detected by the
80/// presence of `#`, e.g. `other-page.adoc#frag`) are treated as inter-document
81/// references and left unresolved — resolving those is the responsibility of a
82/// host-supplied resolver.
83#[derive(Clone, Copy, Debug)]
84pub struct CatalogResolver<'a> {
85    catalog: &'a Catalog,
86}
87
88impl<'a> CatalogResolver<'a> {
89    /// Construct a resolver backed by the given catalog.
90    pub fn new(catalog: &'a Catalog) -> Self {
91        Self { catalog }
92    }
93}
94
95impl ReferenceResolver for CatalogResolver<'_> {
96    fn resolve(&self, context: &ResolutionContext<'_>) -> Option<ResolvedReference> {
97        let target = context.target;
98
99        // Path-bearing (inter-document) targets are a host concern.
100        if target.contains('#') {
101            return None;
102        }
103
104        // Direct ID match.
105        if let Some(entry) = self.catalog.get_ref(target) {
106            return Some(ResolvedReference {
107                href: format!("#{target}"),
108                text: entry.reftext.clone(),
109            });
110        }
111
112        // Natural cross-reference: match on reference text.
113        if let Some(id) = self.catalog.resolve_id(target) {
114            let text = self.catalog.get_ref(&id).and_then(|e| e.reftext.clone());
115            return Some(ResolvedReference {
116                href: format!("#{id}"),
117                text,
118            });
119        }
120
121        None
122    }
123}
124
125#[cfg(test)]
126mod tests {
127    #![allow(clippy::unwrap_used)]
128
129    use super::*;
130    use crate::document::RefType;
131
132    fn catalog_with(id: &str, reftext: Option<&str>, ref_type: RefType) -> Catalog {
133        let mut catalog = Catalog::new();
134        catalog.register_ref(id, reftext, ref_type).unwrap();
135        catalog
136    }
137
138    #[test]
139    fn resolves_by_id() {
140        let catalog = catalog_with("later", Some("The Later Section"), RefType::Section);
141        let resolver = CatalogResolver::new(&catalog);
142
143        let resolved = resolver
144            .resolve(&ResolutionContext {
145                target: "later",
146                provided_text: None,
147            })
148            .unwrap();
149
150        assert_eq!(resolved.href, "#later");
151        assert_eq!(resolved.text.as_deref(), Some("The Later Section"));
152    }
153
154    #[test]
155    fn resolves_by_reftext() {
156        let catalog = catalog_with("later", Some("The Later Section"), RefType::Section);
157        let resolver = CatalogResolver::new(&catalog);
158
159        let resolved = resolver
160            .resolve(&ResolutionContext {
161                target: "The Later Section",
162                provided_text: None,
163            })
164            .unwrap();
165
166        assert_eq!(resolved.href, "#later");
167        assert_eq!(resolved.text.as_deref(), Some("The Later Section"));
168    }
169
170    #[test]
171    fn unresolved_returns_none() {
172        let catalog = Catalog::new();
173        let resolver = CatalogResolver::new(&catalog);
174
175        assert!(
176            resolver
177                .resolve(&ResolutionContext {
178                    target: "missing",
179                    provided_text: None,
180                })
181                .is_none()
182        );
183    }
184
185    #[test]
186    fn path_bearing_target_left_unresolved() {
187        let catalog = catalog_with("frag", Some("Fragment"), RefType::Anchor);
188        let resolver = CatalogResolver::new(&catalog);
189
190        assert!(
191            resolver
192                .resolve(&ResolutionContext {
193                    target: "other-page.adoc#frag",
194                    provided_text: None,
195                })
196                .is_none()
197        );
198    }
199}