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, 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, 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, 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, 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
280/// Resolves cross-reference targets to their destinations.
281///
282/// Implementations map a [`ResolutionContext`] to a [`ResolvedReference`], or
283/// return `None` when the target cannot be resolved (the caller then renders an
284/// unresolved-reference fallback and may emit a warning).
285pub trait ReferenceResolver {
286 /// Resolve a single cross-reference.
287 fn resolve(&self, context: &ResolutionContext<'_>) -> Option<ResolvedReference>;
288}
289
290/// The default single-document [`ReferenceResolver`], backed by one
291/// [`Catalog`].
292///
293/// It resolves bare IDs and natural cross-references (by reference text) to
294/// `#id` fragments. A target that names a document (e.g.
295/// `other-page.adoc#frag`) is left unresolved here, so it falls back to the
296/// [`DerivedReference`] the parser built from the target's path; only a
297/// host-supplied resolver, which can see the other document, can do better.
298#[derive(Clone, Copy, Debug)]
299pub struct CatalogResolver<'a> {
300 catalog: &'a Catalog,
301}
302
303impl<'a> CatalogResolver<'a> {
304 /// Construct a resolver backed by the given catalog.
305 pub fn new(catalog: &'a Catalog) -> Self {
306 Self { catalog }
307 }
308}
309
310impl ReferenceResolver for CatalogResolver<'_> {
311 fn resolve(&self, context: &ResolutionContext<'_>) -> Option<ResolvedReference> {
312 let target = context.target;
313
314 // A target that names a document already carries its destination.
315 if context.derived.is_some() {
316 return None;
317 }
318
319 // Direct ID match.
320 if let Some(entry) = self.catalog.get_ref(target) {
321 return Some(ResolvedReference::from_entry(format!("#{target}"), entry));
322 }
323
324 // Natural cross-reference: match on reference text.
325 if let Some(id) = self.catalog.resolve_id(target) {
326 return self
327 .catalog
328 .get_ref(&id)
329 .map(|entry| ResolvedReference::from_entry(format!("#{id}"), entry));
330 }
331
332 None
333 }
334}
335
336#[cfg(test)]
337mod tests {
338 #![allow(clippy::unwrap_used)]
339
340 use super::*;
341 use crate::document::RefType;
342
343 fn catalog_with(id: &str, reftext: Option<&str>, ref_type: RefType) -> Catalog {
344 let mut catalog = Catalog::new();
345 catalog.register_ref(id, reftext, ref_type).unwrap();
346 catalog
347 }
348
349 #[test]
350 fn resolves_by_id() {
351 let catalog = catalog_with("later", Some("The Later Section"), RefType::Section);
352 let resolver = CatalogResolver::new(&catalog);
353
354 let resolved = resolver
355 .resolve(&ResolutionContext {
356 target: "later",
357 provided_text: None,
358 derived: None,
359 })
360 .unwrap();
361
362 assert_eq!(resolved.href, "#later");
363 assert_eq!(resolved.text.as_deref(), Some("The Later Section"));
364 }
365
366 #[test]
367 fn resolves_by_reftext() {
368 let catalog = catalog_with("later", Some("The Later Section"), RefType::Section);
369 let resolver = CatalogResolver::new(&catalog);
370
371 let resolved = resolver
372 .resolve(&ResolutionContext {
373 target: "The Later Section",
374 provided_text: None,
375 derived: None,
376 })
377 .unwrap();
378
379 assert_eq!(resolved.href, "#later");
380 assert_eq!(resolved.text.as_deref(), Some("The Later Section"));
381 }
382
383 #[test]
384 fn unresolved_returns_none() {
385 let catalog = Catalog::new();
386 let resolver = CatalogResolver::new(&catalog);
387
388 assert!(
389 resolver
390 .resolve(&ResolutionContext {
391 target: "missing",
392 provided_text: None,
393 derived: None,
394 })
395 .is_none()
396 );
397 }
398
399 #[test]
400 fn path_bearing_target_left_unresolved() {
401 let catalog = catalog_with("frag", Some("Fragment"), RefType::Anchor);
402 let resolver = CatalogResolver::new(&catalog);
403
404 assert!(
405 resolver
406 .resolve(&ResolutionContext {
407 target: "other-page.adoc#frag",
408 provided_text: None,
409 derived: Some(&DerivedReference {
410 href: "other-page.html#frag".to_string(),
411 text: "other-page.html".to_string(),
412 }),
413 })
414 .is_none()
415 );
416 }
417
418 #[test]
419 fn numeric_character_reference_is_not_a_path_separator() {
420 let catalog = catalog_with("_cub_tiger", Some("Cub ⇒ Tiger"), RefType::Section);
421 let resolver = CatalogResolver::new(&catalog);
422
423 let resolved = resolver
424 .resolve(&ResolutionContext {
425 target: "Cub ⇒ Tiger",
426 provided_text: None,
427 derived: None,
428 })
429 .unwrap();
430
431 assert_eq!(resolved.href, "#_cub_tiger");
432 assert_eq!(resolved.text.as_deref(), Some("Cub ⇒ Tiger"));
433 }
434}