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 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}