Skip to main content

acdc_parser/model/
anchor.rs

1//! Anchor and reference types for `AsciiDoc` documents.
2
3use serde::{
4    Serialize,
5    ser::{SerializeMap, Serializer},
6};
7
8use super::{
9    caption::Caption,
10    inlines::InlineNode,
11    location::Location,
12    section::{SectionKind, SectionNumber},
13    title::Title,
14};
15
16/// Section styles that should not receive automatic numbering.
17///
18/// When `sectnums` is enabled, sections with these styles are excluded from
19/// the numbering scheme. Appendix uses letter numbering (A, B, C) which is
20/// handled separately.
21pub const UNNUMBERED_SECTION_STYLES: &[&str] = &[
22    "preface",
23    "abstract",
24    "dedication",
25    "colophon",
26    "bibliography",
27    "glossary",
28    "index",
29    "appendix",
30];
31
32/// An `Anchor` represents an anchor in a document.
33///
34/// An anchor is a reference point in a document that can be linked to.
35#[derive(Clone, Debug, Default, PartialEq, Serialize)]
36#[non_exhaustive]
37pub struct Anchor<'a> {
38    pub id: &'a str,
39    #[serde(default, skip_serializing_if = "Option::is_none")]
40    pub xreflabel: Option<&'a str>,
41    pub location: Location,
42    #[serde(skip)]
43    pub(crate) bibliography: bool,
44    #[serde(skip)]
45    pub(crate) bibliography_label: Option<Box<BibliographyLabel<'a>>>,
46}
47
48#[derive(Clone, Debug, PartialEq)]
49pub(crate) struct BibliographyLabel<'a> {
50    pub(crate) source: Option<&'a str>,
51    pub(crate) content: Vec<InlineNode<'a>>,
52}
53
54impl<'a> Anchor<'a> {
55    /// Create a new anchor with the given ID and location.
56    #[must_use]
57    pub fn new(id: &'a str, location: Location) -> Self {
58        Self {
59            id,
60            xreflabel: None,
61            location,
62            bibliography: false,
63            bibliography_label: None,
64        }
65    }
66
67    /// Set the cross-reference label.
68    #[must_use]
69    pub fn with_xreflabel(mut self, xreflabel: Option<&'a str>) -> Self {
70        self.xreflabel = xreflabel;
71        self
72    }
73
74    /// Returns whether this anchor identifies a bibliography entry.
75    #[must_use]
76    pub fn is_bibliography(&self) -> bool {
77        self.bibliography
78    }
79
80    /// Return the entry label after its source-position inline substitutions.
81    ///
82    /// Citation text remains separate in [`Self::xreflabel`]. Returns `None`
83    /// for ordinary anchors and entries that use their ID as the label.
84    #[must_use]
85    pub fn bibliography_label(&self) -> Option<&[InlineNode<'a>]> {
86        self.bibliography
87            .then_some(self.bibliography_label.as_deref())
88            .flatten()
89            .map(|label| label.content.as_slice())
90    }
91}
92
93/// A `TocEntry` represents a table of contents entry.
94///
95/// This is collected during parsing from Section.
96#[derive(Clone, Debug, PartialEq)]
97#[non_exhaustive]
98pub struct TocEntry<'a> {
99    /// Unique identifier for this section (used for anchor links)
100    pub id: &'a str,
101    /// Title of the section
102    pub title: Title<'a>,
103    /// Section level (1 for top-level, 2 for subsection, etc.)
104    pub level: u8,
105    /// Optional cross-reference label from `reftext=` or `[[id,xreflabel]]`.
106    pub xreflabel: Option<&'a str>,
107    /// The section's structural category (special-section style, or `Normal`).
108    /// Converters use it for presentation, such as appendix labels.
109    pub kind: SectionKind,
110    number: Option<SectionNumber>,
111    /// Location of the section heading (the cross-reference target).
112    pub location: Location,
113}
114
115impl<'a> TocEntry<'a> {
116    pub(crate) fn for_section(
117        id: &'a str,
118        title: Title<'a>,
119        level: u8,
120        xreflabel: Option<&'a str>,
121        kind: SectionKind,
122        location: Location,
123    ) -> Self {
124        Self {
125            id,
126            title,
127            level,
128            xreflabel,
129            kind,
130            number: None,
131            location,
132        }
133    }
134
135    pub(super) fn set_number(&mut self, number: Option<SectionNumber>) {
136        self.number = number;
137    }
138
139    /// Return the assigned number without presentation punctuation or a signifier.
140    ///
141    /// Examples are `1`, `1.2`, `IV`, and `A.1`.
142    #[must_use]
143    pub fn number(&self) -> Option<&str> {
144        self.number.as_ref().map(SectionNumber::as_str)
145    }
146}
147
148impl Serialize for TocEntry<'_> {
149    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
150    where
151        S: Serializer,
152    {
153        let mut state = serializer.serialize_map(None)?;
154        state.serialize_entry("id", &self.id)?;
155        state.serialize_entry("title", &self.title)?;
156        state.serialize_entry("level", &self.level)?;
157        if self.xreflabel.is_some() {
158            state.serialize_entry("xreflabel", &self.xreflabel)?;
159        }
160        if let Some(style) = self.kind.as_style() {
161            state.serialize_entry("style", style)?;
162        }
163        state.end()
164    }
165}
166
167/// Derived category and number for styled section references.
168#[derive(Clone, Debug, PartialEq, Eq)]
169pub(crate) struct SectionReference {
170    pub(crate) name: &'static str,
171    pub(super) number: Option<SectionNumber>,
172}
173
174/// Reference metadata for a cross-reference target.
175///
176/// Collected during parsing into the `id → Reference` map on
177/// [`Document::references`](crate::Document), so a `<<id>>` reference resolves
178/// to its target's text in O(1). The id is the map key.
179#[derive(Clone, Debug, PartialEq)]
180#[non_exhaustive]
181pub struct Reference<'a> {
182    /// Optional cross-reference label from `reftext=` or `[[id,xreflabel]]`,
183    /// parsed as inline content. A label carries formatting, so `*Bold* label`
184    /// renders bold. When set, it is the reference text; otherwise converters
185    /// use `title` and, for captioned targets, `caption`.
186    pub xreflabel: Option<Vec<InlineNode<'a>>>,
187    /// The target's title, when it has one. `None` for a referenceable element
188    /// with no title, such as an untitled block or an inline link with an `id`
189    /// attribute. A reference to such a target renders the literal `[id]`,
190    /// unlike an ID that is absent from the catalog and therefore unresolved.
191    pub title: Option<Title<'a>>,
192    /// Location of the target element (for navigation, e.g. LSP go-to-definition).
193    pub location: Location,
194    /// The target block's resolved caption, when it has one.
195    pub caption: Option<Caption<'a>>,
196    pub(crate) section: Option<SectionReference>,
197    pub(crate) bibliography: bool,
198    pub(crate) automatic_citation: bool,
199}
200
201impl Reference<'_> {
202    /// The section's cross-reference category, or `None` for other targets.
203    ///
204    /// This is `part`, `chapter`, `section`, `appendix`, or a special section's
205    /// style such as `preface`. It selects the `<name>-refsig` attribute.
206    #[must_use]
207    pub fn section_name(&self) -> Option<&str> {
208        self.section.as_ref().map(|section| section.name)
209    }
210
211    /// The section's reference number, without a signifier or trailing punctuation.
212    ///
213    /// Returns `None` for unnumbered sections and non-section targets. A section
214    /// beyond `sectnumlevels` can have a reference number without a heading number.
215    ///
216    /// ```
217    /// use acdc_parser::{Options, parse};
218    ///
219    /// let parsed = parse("= Guide\n:sectnums:\n\n[#intro]\n== Introduction\n", &Options::default())?;
220    /// let reference = parsed.document().references.get("intro").ok_or("missing section")?;
221    /// assert_eq!(reference.section_name(), Some("section"));
222    /// assert_eq!(reference.section_number(), Some("1"));
223    /// # Ok::<(), Box<dyn std::error::Error>>(())
224    /// ```
225    #[must_use]
226    pub fn section_number(&self) -> Option<&str> {
227        self.section
228            .as_ref()?
229            .number
230            .as_ref()
231            .map(SectionNumber::as_str)
232    }
233
234    /// Returns whether this target is a bibliography entry.
235    #[must_use]
236    pub fn is_bibliography(&self) -> bool {
237        self.bibliography
238    }
239
240    /// Returns whether the document contains an automatic citation to this target.
241    #[must_use]
242    pub fn has_automatic_citation(&self) -> bool {
243        self.automatic_citation
244    }
245}
246
247#[cfg(test)]
248mod tests {
249    use super::*;
250
251    #[test]
252    fn section_reference_queries_borrow_shared_numbers() -> Result<(), Box<dyn std::error::Error>> {
253        let parsed = crate::parse(
254            include_str!("../../fixtures/tests/section_reference_metadata.adoc"),
255            &crate::Options::default(),
256        )?;
257        let document = parsed.document();
258        for id in ["part", "chapter", "section", "appendix"] {
259            let reference = document.references.get(id).ok_or("missing reference")?;
260            let cloned = reference.clone();
261            let number = reference.section_number().ok_or("missing number")?;
262            let cloned_number = cloned.section_number().ok_or("missing cloned number")?;
263            let entry = document
264                .toc_entries
265                .iter()
266                .find(|entry| entry.id == id)
267                .ok_or("missing TOC entry")?;
268
269            // JSON omits the reference catalog and cannot check shared storage.
270            assert!(std::ptr::eq(number, cloned_number), "{id}");
271            if let Some(heading_number) = entry.number() {
272                assert!(std::ptr::eq(number, heading_number), "{id}");
273            }
274        }
275        let plain = document.references.get("plain").ok_or("missing section")?;
276        assert_eq!(plain.section_name(), Some("section"));
277        assert_eq!(plain.section_number(), None);
278        let block = document.references.get("block").ok_or("missing block")?;
279        assert_eq!(block.section_name(), None);
280        assert_eq!(block.section_number(), None);
281        Ok(())
282    }
283
284    fn toc_entry(kind: SectionKind) -> TocEntry<'static> {
285        TocEntry::for_section(
286            "_intro",
287            Title::default(),
288            1,
289            None,
290            kind,
291            Location::default(),
292        )
293    }
294
295    #[test]
296    fn toc_entry_serializes_special_style() -> Result<(), serde_json::Error> {
297        let json = serde_json::to_value(toc_entry(SectionKind::Preface))?;
298        assert_eq!(json.get("style").and_then(|v| v.as_str()), Some("preface"));
299        Ok(())
300    }
301
302    #[test]
303    fn toc_entry_omits_style_for_normal_section() -> Result<(), serde_json::Error> {
304        let json = serde_json::to_value(toc_entry(SectionKind::Normal))?;
305        assert!(json.get("style").is_none());
306        Ok(())
307    }
308}