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 ⇒ Tiger"), RefType::Section);
456 let resolver = CatalogResolver::new(&catalog);
457
458 let resolved = resolver
459 .resolve(&ResolutionContext {
460 target: "Cub ⇒ 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 ⇒ Tiger"));
468 }
469}