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 {
227 source,
228 warning: WarningType::PossibleInvalidReference(target.to_string()),
229 origin: None,
230 });
231 }
232
233 /// Folds warnings gathered from a privately-owned sub-parse – the blocks of
234 /// a Markdown-style blockquote, or of an include-expanded AsciiDoc table
235 /// cell – into `dest`.
236 ///
237 /// Those blocks borrow their own owned source, so their spans cannot be
238 /// named in the enclosing document. Each document warning is re-anchored to
239 /// `anchor`, the enclosing element's span in the document.
240 pub(crate) fn rehome_into<'outer>(
241 self,
242 dest: &mut ReferenceWarnings<'outer>,
243 anchor: Span<'outer>,
244 ) {
245 dest.host.extend(self.host);
246
247 dest.doc.extend(self.doc.into_iter().map(|warning| Warning {
248 source: anchor,
249 warning: warning.warning,
250 origin: warning.origin,
251 }));
252 }
253}
254
255/// Describes a single cross-reference that needs to be resolved.
256///
257/// This carries only information the crate itself knows about the reference. A
258/// multi-document host that needs to know which document a reference originates
259/// from binds that "from" context when it constructs its [`ReferenceResolver`],
260/// rather than receiving it here – keeping this seam free of any host-specific
261/// coordinate system.
262#[non_exhaustive]
263pub struct ResolutionContext<'a> {
264 /// The raw, uninterpreted cross-reference target, exactly as written in the
265 /// source (e.g. `"section-id"`, a reftext, or `"other-page.adoc#frag"`).
266 pub target: &'a str,
267
268 /// Explicit link text supplied in the cross-reference, if any.
269 pub provided_text: Option<&'a str>,
270
271 /// The destination the parser derived from the target itself, for a
272 /// target that names a document; `None` for a reference to an element
273 /// within the current document.
274 ///
275 /// A resolver that can do better is free to ignore this and return its own
276 /// [`ResolvedReference`]; returning `None` leaves this default in place.
277 pub derived: Option<&'a DerivedReference>,
278}
279
280impl<'a> ResolutionContext<'a> {
281 /// Constructs a [`ResolutionContext`] from its parts.
282 ///
283 /// The crate itself builds these values internally; this constructor exists
284 /// so a downstream [`ReferenceResolver`] implementation can build its own
285 /// contexts in unit tests despite the type being `#[non_exhaustive]`.
286 #[must_use]
287 pub fn new(
288 target: &'a str,
289 provided_text: Option<&'a str>,
290 derived: Option<&'a DerivedReference>,
291 ) -> Self {
292 Self {
293 target,
294 provided_text,
295 derived,
296 }
297 }
298}
299
300/// Resolves cross-reference targets to their destinations.
301///
302/// Implementations map a [`ResolutionContext`] to a [`ResolvedReference`], or
303/// return `None` when the target cannot be resolved (the caller then renders an
304/// unresolved-reference fallback and may emit a warning).
305pub trait ReferenceResolver {
306 /// Resolve a single cross-reference.
307 fn resolve(&self, context: &ResolutionContext<'_>) -> Option<ResolvedReference>;
308}
309
310/// The default single-document [`ReferenceResolver`], backed by one
311/// [`Catalog`].
312///
313/// It resolves bare IDs and natural cross-references (by reference text) to
314/// `#id` fragments. A target that names a document (e.g.
315/// `other-page.adoc#frag`) is left unresolved here, so it falls back to the
316/// [`DerivedReference`] the parser built from the target's path; only a
317/// host-supplied resolver, which can see the other document, can do better.
318#[derive(Clone, Copy, Debug)]
319pub struct CatalogResolver<'a> {
320 catalog: &'a Catalog,
321}
322
323impl<'a> CatalogResolver<'a> {
324 /// Construct a resolver backed by the given catalog.
325 pub fn new(catalog: &'a Catalog) -> Self {
326 Self { catalog }
327 }
328}
329
330impl ReferenceResolver for CatalogResolver<'_> {
331 fn resolve(&self, context: &ResolutionContext<'_>) -> Option<ResolvedReference> {
332 let target = context.target;
333
334 // A target that names a document already carries its destination.
335 if context.derived.is_some() {
336 return None;
337 }
338
339 // Direct ID match.
340 if let Some(entry) = self.catalog.get_ref(target) {
341 return Some(ResolvedReference::from_entry(format!("#{target}"), entry));
342 }
343
344 // Natural cross-reference: match on reference text.
345 if let Some(id) = self.catalog.resolve_id(target) {
346 return self
347 .catalog
348 .get_ref(&id)
349 .map(|entry| ResolvedReference::from_entry(format!("#{id}"), entry));
350 }
351
352 None
353 }
354}
355
356#[cfg(test)]
357mod tests {
358 #![allow(clippy::unwrap_used)]
359
360 use super::*;
361 use crate::document::RefType;
362
363 fn catalog_with(id: &str, reftext: Option<&str>, ref_type: RefType) -> Catalog {
364 let mut catalog = Catalog::new();
365 catalog.register_ref(id, reftext, ref_type).unwrap();
366 catalog
367 }
368
369 #[test]
370 fn resolves_by_id() {
371 let catalog = catalog_with("later", Some("The Later Section"), RefType::Section);
372 let resolver = CatalogResolver::new(&catalog);
373
374 let resolved = resolver
375 .resolve(&ResolutionContext {
376 target: "later",
377 provided_text: None,
378 derived: None,
379 })
380 .unwrap();
381
382 assert_eq!(resolved.href, "#later");
383 assert_eq!(resolved.text.as_deref(), Some("The Later Section"));
384 }
385
386 #[test]
387 fn new_builds_a_resolvable_context() {
388 // The `new` constructor is the seam a downstream `ReferenceResolver`
389 // uses to build its own contexts, since the type is `#[non_exhaustive]`.
390 let catalog = catalog_with("later", Some("The Later Section"), RefType::Section);
391 let resolver = CatalogResolver::new(&catalog);
392
393 let context = ResolutionContext::new("later", None, None);
394 assert_eq!(context.target, "later");
395 assert_eq!(context.provided_text, None);
396 assert!(context.derived.is_none());
397
398 let resolved = resolver.resolve(&context).unwrap();
399 assert_eq!(resolved.href, "#later");
400 }
401
402 #[test]
403 fn resolves_by_reftext() {
404 let catalog = catalog_with("later", Some("The Later Section"), RefType::Section);
405 let resolver = CatalogResolver::new(&catalog);
406
407 let resolved = resolver
408 .resolve(&ResolutionContext {
409 target: "The Later Section",
410 provided_text: None,
411 derived: None,
412 })
413 .unwrap();
414
415 assert_eq!(resolved.href, "#later");
416 assert_eq!(resolved.text.as_deref(), Some("The Later Section"));
417 }
418
419 #[test]
420 fn unresolved_returns_none() {
421 let catalog = Catalog::new();
422 let resolver = CatalogResolver::new(&catalog);
423
424 assert!(
425 resolver
426 .resolve(&ResolutionContext {
427 target: "missing",
428 provided_text: None,
429 derived: None,
430 })
431 .is_none()
432 );
433 }
434
435 #[test]
436 fn path_bearing_target_left_unresolved() {
437 let catalog = catalog_with("frag", Some("Fragment"), RefType::Anchor);
438 let resolver = CatalogResolver::new(&catalog);
439
440 assert!(
441 resolver
442 .resolve(&ResolutionContext {
443 target: "other-page.adoc#frag",
444 provided_text: None,
445 derived: Some(&DerivedReference {
446 href: "other-page.html#frag".to_string(),
447 text: "other-page.html".to_string(),
448 }),
449 })
450 .is_none()
451 );
452 }
453
454 #[test]
455 fn numeric_character_reference_is_not_a_path_separator() {
456 let catalog = catalog_with("_cub_tiger", Some("Cub ⇒ Tiger"), RefType::Section);
457 let resolver = CatalogResolver::new(&catalog);
458
459 let resolved = resolver
460 .resolve(&ResolutionContext {
461 target: "Cub ⇒ Tiger",
462 provided_text: None,
463 derived: None,
464 })
465 .unwrap();
466
467 assert_eq!(resolved.href, "#_cub_tiger");
468 assert_eq!(resolved.text.as_deref(), Some("Cub ⇒ Tiger"));
469 }
470}