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 cross-reference text style selected by the `xrefstyle` attribute.
17///
18/// The style is chosen from the `xrefstyle` value in effect for a reference:
19/// the `xrefstyle=` attribute on the `xref:` macro if present, otherwise the
20/// document-wide `xrefstyle` attribute. It controls how the automatic text of a
21/// cross-reference is generated for a target that carries a reference number
22/// (see [Cross reference styles]).
23///
24/// A reference whose `xrefstyle` is *unset* has no `XrefStyle`; it uses the
25/// target's reference text verbatim. An unrecognized value is treated as
26/// [`Basic`](Self::Basic), mirroring Asciidoctor.
27///
28/// [Cross reference styles]: https://docs.asciidoctor.org/asciidoc/latest/macros/xref-text-and-style/#cross-reference-styles
29#[derive(Clone, Copy, Debug, Eq, PartialEq)]
30pub enum XrefStyle {
31 /// The signifier and number followed by the title, quoted (or emphasized
32 /// for a chapter or appendix): e.g. `Section 2.3, “Installation”`.
33 Full,
34
35 /// The signifier and number only: e.g. `Section 2.3`.
36 Short,
37
38 /// The title only, emphasized for a chapter or appendix: e.g.
39 /// `Installation`.
40 Basic,
41}
42
43impl XrefStyle {
44 /// Interprets an `xrefstyle` attribute value. `full` and `short` select
45 /// those styles; every other value (including `basic` and any unrecognized
46 /// value) yields [`Basic`](Self::Basic), mirroring Asciidoctor.
47 pub(crate) fn parse(value: &str) -> Self {
48 match value {
49 "full" => Self::Full,
50 "short" => Self::Short,
51 _ => Self::Basic,
52 }
53 }
54}
55
56/// A referenceable target's signifier and reference number, used to build the
57/// automatic text of a cross-reference for the [`Full`](XrefStyle::Full) and
58/// [`Short`](XrefStyle::Short) styles (and to emphasize a chapter or appendix
59/// title under [`Basic`](XrefStyle::Basic)).
60///
61/// This carries only the target-derived pieces; how they are combined with the
62/// target's title is decided by the reference's [`XrefStyle`] at render time.
63#[derive(Clone, Debug, Eq, PartialEq)]
64pub struct XrefSignifier {
65 /// The signifier and reference number, already combined (e.g. `"Section
66 /// 2.3"`, `"Figure 1"`, or just `"2.3"` when the target's `*-refsig`
67 /// attribute is unset).
68 pub label: String,
69
70 /// Whether the target's title is emphasized (rendered inside `<em>`) rather
71 /// than quoted. `true` for chapters and appendices.
72 pub emphasize: bool,
73}
74
75/// The resolved destination of a cross-reference.
76#[derive(Clone, Debug, Eq, PartialEq)]
77pub struct ResolvedReference {
78 /// The hyperlink destination. For a same-document reference this is a
79 /// fragment such as `#section-id`; a cross-document resolver may return a
80 /// full or relative URL.
81 pub href: String,
82
83 /// The display text to use when the cross-reference did not specify its own
84 /// text. This is typically the target's reference text (reftext).
85 pub text: Option<String>,
86
87 /// The target's signifier and number, when it carries one and has no
88 /// explicit reftext. Present only for targets eligible for `full`/`short`
89 /// [`xrefstyle`](XrefStyle) formatting (numbered sections and captioned
90 /// blocks); `None` otherwise. Ignored unless the reference selects a style.
91 pub signifier: Option<XrefSignifier>,
92}
93
94impl ResolvedReference {
95 /// Constructs a resolved reference with no [`signifier`](Self::signifier).
96 ///
97 /// Use this when the target is not a numbered/captioned element, or when
98 /// the resolver builds the display `text` from scratch. When the target
99 /// came from a [`Catalog`] (the usual case, including cross-document
100 /// resolution), prefer [`from_entry`](Self::from_entry) so
101 /// `full`/`short` `xrefstyle` formatting keeps working; or attach a
102 /// signifier explicitly with [`with_signifier`](Self::with_signifier).
103 pub fn new(href: String, text: Option<String>) -> Self {
104 Self {
105 href,
106 text,
107 signifier: None,
108 }
109 }
110
111 /// Constructs a resolved reference to a catalog element at `href`, carrying
112 /// the element's reference text **and** its
113 /// [`signifier`](Self::signifier).
114 ///
115 /// This is the seam that makes `full`/`short` `xrefstyle` formatting work
116 /// across documents: a multi-document (Antora-style) resolver that has
117 /// located the target's [`RefEntry`] in some document's [`Catalog`] passes
118 /// the `href` it computed for that document, and the target's signifier and
119 /// number — computed while *that* document was parsed — ride along. The
120 /// style itself comes from the *referencing* document and is applied later,
121 /// so the resolver need not know it. The single-document
122 /// [`CatalogResolver`] is built on this same helper.
123 ///
124 /// [`RefEntry`]: crate::document::RefEntry
125 pub fn from_entry(href: String, entry: &crate::document::RefEntry) -> Self {
126 Self {
127 href,
128 text: entry.reftext.clone(),
129 signifier: entry.signifier.clone(),
130 }
131 }
132
133 /// Attaches a [`signifier`](Self::signifier), returning `self` for
134 /// chaining.
135 ///
136 /// For a host resolver that builds its `href`/`text` from scratch but still
137 /// wants `full`/`short` `xrefstyle` formatting for a numbered or captioned
138 /// target.
139 pub fn with_signifier(mut self, signifier: XrefSignifier) -> Self {
140 self.signifier = Some(signifier);
141 self
142 }
143}
144
145/// A warning produced while resolving cross-references.
146#[derive(Clone, Debug, Eq, PartialEq)]
147pub struct ReferenceWarning {
148 /// The cross-reference target that could not be resolved, exactly as
149 /// written in the source.
150 pub target: String,
151
152 /// The kind of problem encountered.
153 pub kind: ReferenceWarningKind,
154}
155
156/// The kind of problem described by a [`ReferenceWarning`].
157#[derive(Clone, Copy, Debug, Eq, PartialEq)]
158#[non_exhaustive]
159pub enum ReferenceWarningKind {
160 /// The target could not be resolved to any destination.
161 Unresolved,
162}
163
164/// Describes a single cross-reference that needs to be resolved.
165///
166/// This carries only information the crate itself knows about the reference. A
167/// multi-document host that needs to know which document a reference originates
168/// from binds that "from" context when it constructs its [`ReferenceResolver`],
169/// rather than receiving it here — keeping this seam free of any host-specific
170/// coordinate system.
171#[non_exhaustive]
172pub struct ResolutionContext<'a> {
173 /// The raw, uninterpreted cross-reference target, exactly as written in the
174 /// source (e.g. `"section-id"`, a reftext, or `"other-page.adoc#frag"`).
175 pub target: &'a str,
176
177 /// Explicit link text supplied in the cross-reference, if any.
178 pub provided_text: Option<&'a str>,
179}
180
181/// Resolves cross-reference targets to their destinations.
182///
183/// Implementations map a [`ResolutionContext`] to a [`ResolvedReference`], or
184/// return `None` when the target cannot be resolved (the caller then renders an
185/// unresolved-reference fallback and may emit a warning).
186pub trait ReferenceResolver {
187 /// Resolve a single cross-reference.
188 fn resolve(&self, context: &ResolutionContext<'_>) -> Option<ResolvedReference>;
189}
190
191/// The default single-document [`ReferenceResolver`], backed by one
192/// [`Catalog`].
193///
194/// It resolves bare IDs and natural cross-references (by reference text) to
195/// `#id` fragments. Targets that carry a path component (detected by the
196/// presence of `#`, e.g. `other-page.adoc#frag`) are treated as inter-document
197/// references and left unresolved — resolving those is the responsibility of a
198/// host-supplied resolver.
199#[derive(Clone, Copy, Debug)]
200pub struct CatalogResolver<'a> {
201 catalog: &'a Catalog,
202}
203
204impl<'a> CatalogResolver<'a> {
205 /// Construct a resolver backed by the given catalog.
206 pub fn new(catalog: &'a Catalog) -> Self {
207 Self { catalog }
208 }
209}
210
211impl ReferenceResolver for CatalogResolver<'_> {
212 fn resolve(&self, context: &ResolutionContext<'_>) -> Option<ResolvedReference> {
213 let target = context.target;
214
215 // Path-bearing (inter-document) targets are a host concern.
216 if target.contains('#') {
217 return None;
218 }
219
220 // Direct ID match.
221 if let Some(entry) = self.catalog.get_ref(target) {
222 return Some(ResolvedReference::from_entry(format!("#{target}"), entry));
223 }
224
225 // Natural cross-reference: match on reference text.
226 if let Some(id) = self.catalog.resolve_id(target) {
227 return self
228 .catalog
229 .get_ref(&id)
230 .map(|entry| ResolvedReference::from_entry(format!("#{id}"), entry));
231 }
232
233 None
234 }
235}
236
237#[cfg(test)]
238mod tests {
239 #![allow(clippy::unwrap_used)]
240
241 use super::*;
242 use crate::document::RefType;
243
244 fn catalog_with(id: &str, reftext: Option<&str>, ref_type: RefType) -> Catalog {
245 let mut catalog = Catalog::new();
246 catalog.register_ref(id, reftext, ref_type).unwrap();
247 catalog
248 }
249
250 #[test]
251 fn resolves_by_id() {
252 let catalog = catalog_with("later", Some("The Later Section"), RefType::Section);
253 let resolver = CatalogResolver::new(&catalog);
254
255 let resolved = resolver
256 .resolve(&ResolutionContext {
257 target: "later",
258 provided_text: None,
259 })
260 .unwrap();
261
262 assert_eq!(resolved.href, "#later");
263 assert_eq!(resolved.text.as_deref(), Some("The Later Section"));
264 }
265
266 #[test]
267 fn resolves_by_reftext() {
268 let catalog = catalog_with("later", Some("The Later Section"), RefType::Section);
269 let resolver = CatalogResolver::new(&catalog);
270
271 let resolved = resolver
272 .resolve(&ResolutionContext {
273 target: "The Later Section",
274 provided_text: None,
275 })
276 .unwrap();
277
278 assert_eq!(resolved.href, "#later");
279 assert_eq!(resolved.text.as_deref(), Some("The Later Section"));
280 }
281
282 #[test]
283 fn unresolved_returns_none() {
284 let catalog = Catalog::new();
285 let resolver = CatalogResolver::new(&catalog);
286
287 assert!(
288 resolver
289 .resolve(&ResolutionContext {
290 target: "missing",
291 provided_text: None,
292 })
293 .is_none()
294 );
295 }
296
297 #[test]
298 fn path_bearing_target_left_unresolved() {
299 let catalog = catalog_with("frag", Some("Fragment"), RefType::Anchor);
300 let resolver = CatalogResolver::new(&catalog);
301
302 assert!(
303 resolver
304 .resolve(&ResolutionContext {
305 target: "other-page.adoc#frag",
306 provided_text: None,
307 })
308 .is_none()
309 );
310 }
311}