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}