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::{
15    Span,
16    document::Catalog,
17    warnings::{Warning, WarningType},
18};
19
20/// The cross-reference text style selected by the `xrefstyle` attribute.
21///
22/// The style is chosen from the `xrefstyle` value in effect for a reference:
23/// the `xrefstyle=` attribute on the `xref:` macro if present, otherwise the
24/// document-wide `xrefstyle` attribute. It controls how the automatic text of a
25/// cross-reference is generated for a target that carries a reference number
26/// (see [Cross reference styles]).
27///
28/// A reference whose `xrefstyle` is *unset* has no `XrefStyle`; it uses the
29/// target's reference text verbatim. An unrecognized value is treated as
30/// [`Basic`](Self::Basic), mirroring Asciidoctor.
31///
32/// [Cross reference styles]: https://docs.asciidoctor.org/asciidoc/latest/macros/xref-text-and-style/#cross-reference-styles
33#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
34pub enum XrefStyle {
35    /// The signifier and number followed by the title, quoted (or emphasized
36    /// for a chapter or appendix): e.g. `Section 2.3, “Installation”`.
37    Full,
38
39    /// The signifier and number only: e.g. `Section 2.3`.
40    Short,
41
42    /// The title only, emphasized for a chapter or appendix: e.g.
43    /// `Installation`.
44    Basic,
45}
46
47impl XrefStyle {
48    /// Interprets an `xrefstyle` attribute value. `full` and `short` select
49    /// those styles; every other value (including `basic` and any unrecognized
50    /// value) yields [`Basic`](Self::Basic), mirroring Asciidoctor.
51    pub(crate) fn parse(value: &str) -> Self {
52        match value {
53            "full" => Self::Full,
54            "short" => Self::Short,
55            _ => Self::Basic,
56        }
57    }
58}
59
60/// A referenceable target's signifier and reference number, used to build the
61/// automatic text of a cross-reference for the [`Full`](XrefStyle::Full) and
62/// [`Short`](XrefStyle::Short) styles (and to emphasize a chapter or appendix
63/// title under [`Basic`](XrefStyle::Basic)).
64///
65/// This carries only the target-derived pieces; how they are combined with the
66/// target's title is decided by the reference's [`XrefStyle`] at render time.
67#[derive(Clone, Debug, Eq, Hash, PartialEq)]
68pub struct XrefSignifier {
69    /// The signifier and reference number, already combined (e.g. `"Section
70    /// 2.3"`, `"Figure 1"`, or just `"2.3"` when the target's `*-refsig`
71    /// attribute is unset).
72    pub label: String,
73
74    /// Whether the target's title is emphasized (rendered inside `<em>`) rather
75    /// than quoted. `true` for chapters and appendices.
76    pub emphasize: bool,
77}
78
79/// The resolved destination of a cross-reference.
80#[derive(Clone, Debug, Eq, Hash, PartialEq)]
81pub struct ResolvedReference {
82    /// The hyperlink destination. For a same-document reference this is a
83    /// fragment such as `#section-id`; a cross-document resolver may return a
84    /// full or relative URL.
85    pub href: String,
86
87    /// The display text to use when the cross-reference did not specify its own
88    /// text. This is typically the target's reference text (reftext).
89    pub text: Option<String>,
90
91    /// The target's signifier and number, when it carries one and has no
92    /// explicit reftext. Present only for targets eligible for `full`/`short`
93    /// [`xrefstyle`](XrefStyle) formatting (numbered sections and captioned
94    /// blocks); `None` otherwise. Ignored unless the reference selects a style.
95    pub signifier: Option<XrefSignifier>,
96}
97
98impl ResolvedReference {
99    /// Constructs a resolved reference with no [`signifier`](Self::signifier).
100    ///
101    /// Use this when the target is not a numbered/captioned element, or when
102    /// the resolver builds the display `text` from scratch. When the target
103    /// came from a [`Catalog`] (the usual case, including cross-document
104    /// resolution), prefer [`from_entry`](Self::from_entry) so
105    /// `full`/`short` `xrefstyle` formatting keeps working; or attach a
106    /// signifier explicitly with [`with_signifier`](Self::with_signifier).
107    pub fn new(href: String, text: Option<String>) -> Self {
108        Self {
109            href,
110            text,
111            signifier: None,
112        }
113    }
114
115    /// Constructs a resolved reference to a catalog element at `href`, carrying
116    /// the element's reference text **and** its
117    /// [`signifier`](Self::signifier).
118    ///
119    /// This is the seam that makes `full`/`short` `xrefstyle` formatting work
120    /// across documents: a multi-document (Antora-style) resolver that has
121    /// located the target's [`RefEntry`] in some document's [`Catalog`] passes
122    /// the `href` it computed for that document, and the target's signifier and
123    /// number – computed while *that* document was parsed – ride along. The
124    /// style itself comes from the *referencing* document and is applied later,
125    /// so the resolver need not know it. The single-document
126    /// [`CatalogResolver`] is built on this same helper.
127    ///
128    /// [`RefEntry`]: crate::document::RefEntry
129    pub fn from_entry(href: String, entry: &crate::document::RefEntry) -> Self {
130        Self {
131            href,
132            text: entry.reftext.clone(),
133            signifier: entry.signifier.clone(),
134        }
135    }
136
137    /// Attaches a [`signifier`](Self::signifier), returning `self` for
138    /// chaining.
139    ///
140    /// For a host resolver that builds its `href`/`text` from scratch but still
141    /// wants `full`/`short` `xrefstyle` formatting for a numbered or captioned
142    /// target.
143    pub fn with_signifier(mut self, signifier: XrefSignifier) -> Self {
144        self.signifier = Some(signifier);
145        self
146    }
147}
148
149/// The destination a cross-reference target resolves to on its own, without
150/// consulting any catalog.
151///
152/// A target that names a document – another one (an [inter-document cross
153/// reference]) or the current one – carries its own destination. The parser
154/// derives it while substituting the reference, rewriting the path with the
155/// `relfileprefix`, `relfilesuffix`, and `outfilesuffix` attributes in effect
156/// at that point in the document.
157///
158/// This is a *default*: a [`ReferenceResolver`] that knows better (an
159/// Antora-style host that resolves targets across a corpus) may still return
160/// its own [`ResolvedReference`], which takes precedence. This is what is used
161/// when it does not.
162///
163/// [inter-document cross reference]: https://docs.asciidoctor.org/asciidoc/latest/macros/inter-document-xref/
164#[derive(Clone, Debug, Eq, Hash, PartialEq)]
165pub struct DerivedReference {
166    /// The hyperlink destination: the rewritten output path plus the target's
167    /// fragment, if it had one (e.g. `tigers.html#about`), or `#` for a
168    /// reference to the current document.
169    pub href: String,
170
171    /// The display text to use when the cross-reference did not supply its
172    /// own.
173    ///
174    /// For another document this is its output path (e.g. `tigers.html`),
175    /// since that document's reference text is not available to a
176    /// single-document parse. For the current document it is the document's
177    /// `reftext` or, failing that, its title.
178    pub text: String,
179}
180
181/// A warning produced while resolving cross-references.
182#[derive(Clone, Debug, Eq, PartialEq)]
183pub struct ReferenceWarning {
184    /// The cross-reference target that could not be resolved, exactly as
185    /// written in the source.
186    pub target: String,
187
188    /// The kind of problem encountered.
189    pub kind: ReferenceWarningKind,
190}
191
192/// The kind of problem described by a [`ReferenceWarning`].
193#[derive(Clone, Copy, Debug, Eq, PartialEq)]
194#[non_exhaustive]
195pub enum ReferenceWarningKind {
196    /// The target could not be resolved to any destination.
197    Unresolved,
198}
199
200/// Accumulates what a cross-reference resolution sweep found, in the two forms
201/// the crate needs to report it.
202///
203/// Both lists describe the same conditions: [`host`](Self::host) is handed back
204/// to whoever drove the sweep (and is the crate's public resolution API), while
205/// [`doc`](Self::doc) is folded into the document's own
206/// [warnings](crate::Document::warnings) so an unresolved reference shows up
207/// alongside every other parse-time diagnostic.
208#[derive(Default)]
209pub(crate) struct ReferenceWarnings<'src> {
210    /// The warnings returned from the resolution pass.
211    pub(crate) host: Vec<ReferenceWarning>,
212
213    /// The same warnings, anchored to the source they were found in.
214    pub(crate) doc: Vec<Warning<'src>>,
215}
216
217impl<'src> ReferenceWarnings<'src> {
218    /// Records a target that `resolver` could not resolve, found within
219    /// `source`.
220    pub(crate) fn unresolved(&mut self, target: &str, source: Span<'src>) {
221        self.host.push(ReferenceWarning {
222            target: target.to_string(),
223            kind: ReferenceWarningKind::Unresolved,
224        });
225
226        self.doc.push(Warning::new(
227            source,
228            WarningType::PossibleInvalidReference(target.to_string()),
229        ));
230    }
231
232    /// Folds warnings gathered from a privately-owned sub-parse – the blocks of
233    /// a Markdown-style blockquote, or of an include-expanded AsciiDoc table
234    /// cell – into `dest`.
235    ///
236    /// Those blocks borrow their own owned source, so their spans cannot be
237    /// named in the enclosing document. Each document warning is re-anchored to
238    /// `anchor`, the enclosing element's span in the document.
239    pub(crate) fn rehome_into<'outer>(
240        self,
241        dest: &mut ReferenceWarnings<'outer>,
242        anchor: Span<'outer>,
243    ) {
244        dest.host.extend(self.host);
245
246        dest.doc.extend(
247            self.doc
248                .into_iter()
249                .map(|warning| Warning::with_origin(anchor, warning.warning, warning.origin)),
250        );
251    }
252}
253
254/// Describes a single cross-reference that needs to be resolved.
255///
256/// This carries only information the crate itself knows about the reference. A
257/// multi-document host that needs to know which document a reference originates
258/// from binds that "from" context when it constructs its [`ReferenceResolver`],
259/// rather than receiving it here – keeping this seam free of any host-specific
260/// coordinate system.
261#[non_exhaustive]
262pub struct ResolutionContext<'a> {
263    /// The raw, uninterpreted cross-reference target, exactly as written in the
264    /// source (e.g. `"section-id"`, a reftext, or `"other-page.adoc#frag"`).
265    pub target: &'a str,
266
267    /// Explicit link text supplied in the cross-reference, if any.
268    pub provided_text: Option<&'a str>,
269
270    /// The destination the parser derived from the target itself, for a
271    /// target that names a document; `None` for a reference to an element
272    /// within the current document.
273    ///
274    /// A resolver that can do better is free to ignore this and return its own
275    /// [`ResolvedReference`]; returning `None` leaves this default in place.
276    pub derived: Option<&'a DerivedReference>,
277}
278
279impl<'a> ResolutionContext<'a> {
280    /// Constructs a [`ResolutionContext`] from its parts.
281    ///
282    /// The crate itself builds these values internally; this constructor exists
283    /// so a downstream [`ReferenceResolver`] implementation can build its own
284    /// contexts in unit tests despite the type being `#[non_exhaustive]`.
285    #[must_use]
286    pub fn new(
287        target: &'a str,
288        provided_text: Option<&'a str>,
289        derived: Option<&'a DerivedReference>,
290    ) -> Self {
291        Self {
292            target,
293            provided_text,
294            derived,
295        }
296    }
297}
298
299/// Resolves cross-reference targets to their destinations.
300///
301/// Implementations map a [`ResolutionContext`] to a [`ResolvedReference`], or
302/// return `None` when the target cannot be resolved (the caller then renders an
303/// unresolved-reference fallback and may emit a warning).
304pub trait ReferenceResolver {
305    /// Resolve a single cross-reference.
306    fn resolve(&self, context: &ResolutionContext<'_>) -> Option<ResolvedReference>;
307}
308
309/// The default single-document [`ReferenceResolver`], backed by one
310/// [`Catalog`].
311///
312/// It resolves bare IDs and natural cross-references (by reference text) to
313/// `#id` fragments. A target that names a document (e.g.
314/// `other-page.adoc#frag`) is left unresolved here, so it falls back to the
315/// [`DerivedReference`] the parser built from the target's path; only a
316/// host-supplied resolver, which can see the other document, can do better.
317#[derive(Clone, Copy, Debug)]
318pub struct CatalogResolver<'a> {
319    catalog: &'a Catalog,
320}
321
322impl<'a> CatalogResolver<'a> {
323    /// Construct a resolver backed by the given catalog.
324    pub fn new(catalog: &'a Catalog) -> Self {
325        Self { catalog }
326    }
327}
328
329impl ReferenceResolver for CatalogResolver<'_> {
330    fn resolve(&self, context: &ResolutionContext<'_>) -> Option<ResolvedReference> {
331        let target = context.target;
332
333        // A target that names a document already carries its destination.
334        if context.derived.is_some() {
335            return None;
336        }
337
338        // Direct ID match.
339        if let Some(entry) = self.catalog.get_ref(target) {
340            return Some(ResolvedReference::from_entry(format!("#{target}"), entry));
341        }
342
343        // Natural cross-reference: match on reference text.
344        if let Some(id) = self.catalog.resolve_id(target) {
345            return self
346                .catalog
347                .get_ref(&id)
348                .map(|entry| ResolvedReference::from_entry(format!("#{id}"), entry));
349        }
350
351        None
352    }
353}
354
355#[cfg(test)]
356mod tests {
357    #![allow(clippy::unwrap_used)]
358
359    use super::*;
360    use crate::document::RefType;
361
362    fn catalog_with(id: &str, reftext: Option<&str>, ref_type: RefType) -> Catalog {
363        let mut catalog = Catalog::new();
364        catalog.register_ref(id, reftext, ref_type).unwrap();
365        catalog
366    }
367
368    #[test]
369    fn resolves_by_id() {
370        let catalog = catalog_with("later", Some("The Later Section"), RefType::Section);
371        let resolver = CatalogResolver::new(&catalog);
372
373        let resolved = resolver
374            .resolve(&ResolutionContext {
375                target: "later",
376                provided_text: None,
377                derived: None,
378            })
379            .unwrap();
380
381        assert_eq!(resolved.href, "#later");
382        assert_eq!(resolved.text.as_deref(), Some("The Later Section"));
383    }
384
385    #[test]
386    fn new_builds_a_resolvable_context() {
387        // The `new` constructor is the seam a downstream `ReferenceResolver`
388        // uses to build its own contexts, since the type is `#[non_exhaustive]`.
389        let catalog = catalog_with("later", Some("The Later Section"), RefType::Section);
390        let resolver = CatalogResolver::new(&catalog);
391
392        let context = ResolutionContext::new("later", None, None);
393        assert_eq!(context.target, "later");
394        assert_eq!(context.provided_text, None);
395        assert!(context.derived.is_none());
396
397        let resolved = resolver.resolve(&context).unwrap();
398        assert_eq!(resolved.href, "#later");
399    }
400
401    #[test]
402    fn resolves_by_reftext() {
403        let catalog = catalog_with("later", Some("The Later Section"), RefType::Section);
404        let resolver = CatalogResolver::new(&catalog);
405
406        let resolved = resolver
407            .resolve(&ResolutionContext {
408                target: "The Later Section",
409                provided_text: None,
410                derived: None,
411            })
412            .unwrap();
413
414        assert_eq!(resolved.href, "#later");
415        assert_eq!(resolved.text.as_deref(), Some("The Later Section"));
416    }
417
418    #[test]
419    fn unresolved_returns_none() {
420        let catalog = Catalog::new();
421        let resolver = CatalogResolver::new(&catalog);
422
423        assert!(
424            resolver
425                .resolve(&ResolutionContext {
426                    target: "missing",
427                    provided_text: None,
428                    derived: None,
429                })
430                .is_none()
431        );
432    }
433
434    #[test]
435    fn path_bearing_target_left_unresolved() {
436        let catalog = catalog_with("frag", Some("Fragment"), RefType::Anchor);
437        let resolver = CatalogResolver::new(&catalog);
438
439        assert!(
440            resolver
441                .resolve(&ResolutionContext {
442                    target: "other-page.adoc#frag",
443                    provided_text: None,
444                    derived: Some(&DerivedReference {
445                        href: "other-page.html#frag".to_string(),
446                        text: "other-page.html".to_string(),
447                    }),
448                })
449                .is_none()
450        );
451    }
452
453    #[test]
454    fn numeric_character_reference_is_not_a_path_separator() {
455        let catalog = catalog_with("_cub_tiger", Some("Cub &#8658; Tiger"), RefType::Section);
456        let resolver = CatalogResolver::new(&catalog);
457
458        let resolved = resolver
459            .resolve(&ResolutionContext {
460                target: "Cub &#8658; Tiger",
461                provided_text: None,
462                derived: None,
463            })
464            .unwrap();
465
466        assert_eq!(resolved.href, "#_cub_tiger");
467        assert_eq!(resolved.text.as_deref(), Some("Cub &#8658; Tiger"));
468    }
469}