Skip to main content

acdc_parser/model/inlines/
macros.rs

1use std::{fmt, num::NonZeroUsize};
2
3use serde::Serialize;
4
5use crate::{ElementAttributes, InlineNode, Location, Source, StemNotation, Substitution};
6
7pub const ICON_SIZES: &[&str] = &["1x", "2x", "3x", "4x", "5x", "lg", "fw"];
8
9/// A `Pass` represents a passthrough macro in a document.
10#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
11#[non_exhaustive]
12pub struct Pass<'a> {
13    #[serde(skip)]
14    pub(crate) attribute_fragments: Box<[crate::model::AttributeInlineFragment]>,
15    #[serde(default, skip_serializing_if = "Option::is_none")]
16    pub text: Option<&'a str>,
17    #[serde(default, skip_serializing_if = "Vec::is_empty")]
18    pub substitutions: Vec<Substitution>,
19    pub location: Location,
20    #[serde(skip)]
21    pub kind: PassthroughKind,
22}
23
24#[derive(Clone, Debug, Eq, PartialEq, Serialize, Default)]
25pub enum PassthroughKind {
26    #[default]
27    Single,
28    Double,
29    Triple,
30    Macro,
31    /// Character replacement attribute expanded as passthrough (e.g., `{plus}` → `+`).
32    /// The location spans the `{attr}` reference, not delimiters.
33    AttributeRef,
34}
35
36/// A `Footnote` represents an inline footnote in a document.
37#[derive(Clone, Debug, PartialEq, Serialize)]
38#[non_exhaustive]
39pub struct Footnote<'a> {
40    /// Keep discarded named bodies available for conflict diagnostics.
41    #[serde(skip)]
42    pub(crate) definition_source: Option<&'a str>,
43    /// Capture the body before later substitutions; consumed after source mapping.
44    #[serde(skip)]
45    pub(crate) registration_substitutions: Option<crate::model::substitution::SubstitutionPlan>,
46    #[serde(default, skip_serializing_if = "Option::is_none")]
47    pub id: Option<&'a str>,
48    #[serde(default, skip_serializing_if = "Vec::is_empty")]
49    pub content: Vec<InlineNode<'a>>,
50    #[serde(skip)]
51    pub number: u32,
52    pub location: Location,
53}
54
55/// An `Icon` represents an inline icon in a document.
56#[derive(Clone, Debug, PartialEq, Serialize)]
57#[non_exhaustive]
58pub struct Icon<'a> {
59    pub target: Source<'a>,
60    pub attributes: ElementAttributes<'a>,
61    pub location: Location,
62}
63
64/// A `Link` represents an inline link in a document.
65#[derive(Clone, Debug, PartialEq, Serialize)]
66#[non_exhaustive]
67pub struct Link<'a> {
68    #[serde(skip_serializing)]
69    pub text: Vec<InlineNode<'a>>,
70    pub target: Source<'a>,
71    pub attributes: ElementAttributes<'a>,
72    pub location: Location,
73    #[serde(skip)]
74    pub(crate) hide_uri_scheme: bool,
75}
76
77impl<'a> Link<'a> {
78    /// Creates a new `Link` with the given target.
79    #[must_use]
80    pub fn new(target: Source<'a>, location: Location) -> Self {
81        Self {
82            text: Vec::new(),
83            target,
84            attributes: ElementAttributes::default(),
85            location,
86            hide_uri_scheme: false,
87        }
88    }
89
90    /// Sets the link text as inline nodes.
91    #[must_use]
92    pub fn with_text(mut self, text: Vec<InlineNode<'a>>) -> Self {
93        self.text = text;
94        self
95    }
96
97    /// Sets the link attributes.
98    #[must_use]
99    pub fn with_attributes(mut self, attributes: ElementAttributes<'a>) -> Self {
100        self.attributes = attributes;
101        self
102    }
103
104    /// Whether fallback display text omits the target's URI scheme.
105    #[must_use]
106    pub fn hides_uri_scheme(&self) -> bool {
107        self.hide_uri_scheme
108    }
109}
110
111/// An `Url` represents an inline URL in a document.
112#[derive(Clone, Debug, PartialEq, Serialize)]
113#[non_exhaustive]
114pub struct Url<'a> {
115    #[serde(skip_serializing)]
116    pub text: Vec<InlineNode<'a>>,
117    pub target: Source<'a>,
118    pub attributes: ElementAttributes<'a>,
119    pub location: Location,
120    #[serde(skip)]
121    pub(crate) hide_uri_scheme: bool,
122}
123
124impl Url<'_> {
125    /// Whether fallback display text omits the target's URI scheme.
126    #[must_use]
127    pub fn hides_uri_scheme(&self) -> bool {
128        self.hide_uri_scheme
129    }
130}
131
132/// An `Mailto` represents an inline `mailto:` in a document.
133#[derive(Clone, Debug, PartialEq, Serialize)]
134#[non_exhaustive]
135pub struct Mailto<'a> {
136    #[serde(skip_serializing)]
137    pub text: Vec<InlineNode<'a>>,
138    pub target: Source<'a>,
139    /// The second positional argument, before URI encoding.
140    #[serde(skip_serializing_if = "Option::is_none")]
141    pub subject: Option<&'a str>,
142    /// The third positional argument, before URI encoding.
143    #[serde(skip_serializing_if = "Option::is_none")]
144    pub body: Option<&'a str>,
145    pub attributes: ElementAttributes<'a>,
146    pub location: Location,
147}
148
149/// A `Button` represents an inline button in a document.
150#[derive(Clone, Debug, PartialEq, Serialize)]
151#[non_exhaustive]
152pub struct Button<'a> {
153    pub label: &'a str,
154    pub location: Location,
155}
156
157/// A `Menu` represents an inline menu in a document.
158#[derive(Clone, Debug, PartialEq, Serialize)]
159#[non_exhaustive]
160pub struct Menu<'a> {
161    pub target: &'a str,
162    #[serde(default, skip_serializing_if = "Vec::is_empty")]
163    pub items: Vec<&'a str>,
164    pub location: Location,
165}
166
167/// A `Keyboard` represents an inline keyboard shortcut in a document.
168#[derive(Clone, Debug, PartialEq, Serialize)]
169#[non_exhaustive]
170pub struct Keyboard<'a> {
171    pub keys: Vec<Key<'a>>,
172    pub location: Location,
173}
174
175impl<'a> Keyboard<'a> {
176    /// Creates a new `Keyboard` with the given keys.
177    #[must_use]
178    pub fn new(keys: Vec<Key<'a>>, location: Location) -> Self {
179        Self { keys, location }
180    }
181}
182
183// TODO(nlopes): this could perhaps be an enum instead with the allowed keys
184pub type Key<'a> = &'a str;
185
186/// A `CrossReference` represents an inline cross-reference (xref) in a document.
187///
188/// Equality and debug output include `target`, `text`, `location`, `xrefstyle`,
189/// `caption_label`, `signifier`, `role`, and `target_is_local`; parser-only state is excluded.
190#[derive(Clone, Serialize)]
191#[non_exhaustive]
192pub struct CrossReference<'a> {
193    /// The effective link target. A resolved natural reference contains the
194    /// matching ID; references to fully included sources contain the fragment ID.
195    /// An empty local target addresses the document top. Unresolved references
196    /// retain their reference text or fragment ID.
197    pub target: &'a str,
198    /// Whether `target` addresses this document, even when its ID is missing.
199    /// Set this together with `target` when changing a reference destination.
200    #[serde(skip)]
201    pub target_is_local: bool,
202    #[serde(skip_serializing)]
203    pub text: Vec<InlineNode<'a>>,
204    pub location: Location,
205    #[serde(skip)]
206    pub xrefstyle: XrefStyle,
207    #[serde(skip)]
208    pub caption_label: XrefCaptionLabel<'a>,
209    #[serde(skip)]
210    signifier: XrefSignifier<'a>,
211    #[serde(skip)]
212    pub(crate) section_signifiers: Option<&'a [XrefSignifier<'a>; REFSIG_ATTRIBUTES.len()]>,
213    /// The `role=` of an `xref:` macro after attribute substitution and
214    /// passthrough restoration. It may be empty; HTML uses it as the link's class.
215    #[serde(skip)]
216    pub role: Option<&'a str>,
217    #[serde(skip)]
218    pub(crate) caption_label_snapshot_id: Option<NonZeroUsize>,
219    #[serde(skip)]
220    pub(crate) resolve_natural_target: bool,
221    #[serde(skip)]
222    pub(crate) source_syntax: XrefSourceSyntax,
223}
224
225impl<'a> CrossReference<'a> {
226    /// Creates a new `CrossReference` with the given target. Bare IDs default
227    /// to local targets; paths with extensions or schemes default to external.
228    /// Set `target_is_local` explicitly for an ID that resembles a filename.
229    #[must_use]
230    pub fn new(target: &'a str, location: Location) -> Self {
231        Self {
232            target,
233            target_is_local: Self::is_local_target(target),
234            text: Vec::new(),
235            location,
236            xrefstyle: XrefStyle::Default,
237            caption_label: XrefCaptionLabel::AtTarget,
238            signifier: XrefSignifier::Standard,
239            section_signifiers: None,
240            role: None,
241            caption_label_snapshot_id: None,
242            resolve_natural_target: false,
243            source_syntax: XrefSourceSyntax::Literal,
244        }
245    }
246
247    pub(crate) fn is_local_target(target: &str) -> bool {
248        match target.split_once('#') {
249            Some((path, _)) => path.is_empty(),
250            None => !target.contains(['.', ':']),
251        }
252    }
253
254    /// Sets the cross-reference display text as inline nodes.
255    #[must_use]
256    pub fn with_text(mut self, text: Vec<InlineNode<'a>>) -> Self {
257        self.text = text;
258        self
259    }
260
261    /// The word before a section number, using the attributes at this reference's
262    /// source position unless overridden by [`Self::set_signifier`].
263    #[must_use]
264    pub const fn signifier(&self) -> XrefSignifier<'a> {
265        self.signifier
266    }
267
268    /// Override the section signifier, including after subsequent calls to
269    /// [`Document::renumber_sections`](crate::Document::renumber_sections).
270    pub fn set_signifier(&mut self, signifier: XrefSignifier<'a>) {
271        self.signifier = signifier;
272        self.section_signifiers = None;
273    }
274
275    pub(crate) fn refresh_signifier(&mut self, name: Option<&str>) {
276        let Some(signifiers) = self.section_signifiers else {
277            return;
278        };
279        self.signifier = name
280            .and_then(|name| {
281                REFSIG_ATTRIBUTES
282                    .iter()
283                    .position(|(candidate, _)| *candidate == name)
284            })
285            .and_then(|index| signifiers.get(index))
286            .copied()
287            .unwrap_or_default();
288    }
289}
290
291impl fmt::Debug for CrossReference<'_> {
292    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
293        formatter
294            .debug_struct("CrossReference")
295            .field("target", &self.target)
296            .field("target_is_local", &self.target_is_local)
297            .field("text", &self.text)
298            .field("location", &self.location)
299            .field("xrefstyle", &self.xrefstyle)
300            .field("caption_label", &self.caption_label)
301            .field("signifier", &self.signifier)
302            .field("role", &self.role)
303            .finish()
304    }
305}
306
307impl PartialEq for CrossReference<'_> {
308    fn eq(&self, other: &Self) -> bool {
309        self.target == other.target
310            && self.target_is_local == other.target_is_local
311            && self.text == other.text
312            && self.location == other.location
313            && self.xrefstyle == other.xrefstyle
314            && self.caption_label == other.caption_label
315            && self.signifier == other.signifier
316            && self.role == other.role
317    }
318}
319
320#[derive(Clone, Copy, Debug)]
321pub(crate) enum XrefSourceSyntax {
322    Literal,
323    Shorthand,
324    Macro,
325}
326
327impl XrefSourceSyntax {
328    pub(crate) fn target_is_local(self, target: &str) -> bool {
329        // Compatibility mode treats the entire target as an ID. Otherwise a
330        // colon is ID punctuation; only a path before # or a macro filename
331        // selects another document. Shorthand permits dots in local IDs.
332        if matches!(self, Self::Literal) {
333            return true;
334        }
335        match target.split_once('#') {
336            Some((path, _)) => path.is_empty(),
337            None => {
338                matches!(self, Self::Shorthand)
339                    || !target.rsplit('/').next().unwrap_or(target).contains('.')
340            }
341        }
342    }
343}
344
345/// The word that introduces a section number in an automatic cross-reference.
346///
347/// Asciidoctor reads it from `<name>-refsig` where the reference is written,
348/// so a change part-way through a document applies to the references after it.
349#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
350#[non_exhaustive]
351pub enum XrefSignifier<'a> {
352    /// Use the standard word for the section's category. This is the default
353    /// for references built outside the parser.
354    #[default]
355    Standard,
356    /// Use this word before the number. Parsed references record `<name>-refsig`
357    /// at their source position. An empty value still leaves a space before
358    /// the number, as Asciidoctor does.
359    AtReference(&'a str),
360    /// Show the number alone. Parsed references select this when `<name>-refsig`
361    /// was unset at their source position.
362    Omitted,
363}
364
365// Capture every category before the target is known, and retain them so that
366// renumbering can select a different category without losing source-position values.
367pub(crate) const REFSIG_ATTRIBUTES: [(&str, &str); 11] = [
368    ("part", "part-refsig"),
369    ("chapter", "chapter-refsig"),
370    ("section", "section-refsig"),
371    ("appendix", "appendix-refsig"),
372    ("preface", "preface-refsig"),
373    ("abstract", "abstract-refsig"),
374    ("dedication", "dedication-refsig"),
375    ("colophon", "colophon-refsig"),
376    ("glossary", "glossary-refsig"),
377    ("bibliography", "bibliography-refsig"),
378    ("index", "index-refsig"),
379];
380
381/// Selects the label used by an automatic cross-reference to a numbered caption.
382#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
383#[non_exhaustive]
384pub enum XrefCaptionLabel<'a> {
385    /// Use the caption label recorded on the target.
386    #[default]
387    AtTarget,
388    /// Use the caption label active at the reference position.
389    AtReference(&'a str),
390    /// Omit the label and show only the caption number.
391    NumberOnly,
392}
393
394/// The display style for an automatic cross-reference.
395///
396/// Selected styles fall back to [`Self::Basic`] when the target has no number
397/// or custom caption prefix. Explicit reference text takes precedence.
398#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
399#[non_exhaustive]
400pub enum XrefStyle {
401    /// Use the target title as written, without automatic emphasis.
402    #[default]
403    Default,
404    /// Use the target title, emphasizing chapter and appendix titles.
405    Basic,
406    /// Use only the target's caption label and number or custom prefix.
407    Short,
408    /// Use the caption prefix followed by the target title.
409    Full,
410}
411
412impl XrefStyle {
413    pub(crate) fn from_attribute(value: Option<&str>) -> Self {
414        match value {
415            None => Self::Default,
416            Some("short") => Self::Short,
417            Some("full") => Self::Full,
418            _ => Self::Basic,
419        }
420    }
421}
422
423/// An `Autolink` represents an inline autolink in a document.
424#[derive(Clone, Debug, PartialEq, Serialize)]
425#[non_exhaustive]
426pub struct Autolink<'a> {
427    pub url: Source<'a>,
428    /// Whether the autolink was written with angle brackets (e.g., `<user@example.com>`).
429    /// When true, the renderer should preserve the brackets in the output.
430    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
431    pub bracketed: bool,
432    pub location: Location,
433    #[serde(skip)]
434    pub(crate) hide_uri_scheme: bool,
435}
436
437impl Autolink<'_> {
438    /// Whether fallback display text omits the target's URI scheme.
439    #[must_use]
440    pub fn hides_uri_scheme(&self) -> bool {
441        self.hide_uri_scheme
442    }
443}
444
445/// A `Stem` represents an inline mathematical expression.
446#[derive(Clone, Debug, PartialEq, Serialize)]
447#[non_exhaustive]
448pub struct Stem<'a> {
449    pub content: &'a str,
450    pub notation: StemNotation,
451    pub location: Location,
452}
453
454/// The kind of index term, encoding both visibility and structure.
455#[derive(Clone, Debug, PartialEq, Serialize)]
456#[non_exhaustive]
457pub enum IndexTermKind<'a> {
458    /// A single term that is visible in the document and included in the index.
459    Flow(Vec<InlineNode<'a>>),
460    /// Hidden from output, supports hierarchical entries.
461    Concealed {
462        /// The primary inline label.
463        term: Vec<InlineNode<'a>>,
464        /// The secondary inline label, if present.
465        #[serde(default, skip_serializing_if = "Option::is_none")]
466        secondary: Option<Vec<InlineNode<'a>>>,
467        /// The tertiary inline label, if present.
468        #[serde(default, skip_serializing_if = "Option::is_none")]
469        tertiary: Option<Vec<InlineNode<'a>>>,
470    },
471}
472
473/// A relationship from an index entry to another index term.
474#[derive(Clone, Debug, PartialEq, Serialize)]
475#[serde(tag = "kind", rename_all = "kebab-case")]
476#[non_exhaustive]
477pub enum IndexTermRelationship<'a> {
478    /// Readers should use the target term instead of this entry.
479    See {
480        /// The replacement index term.
481        target: Vec<InlineNode<'a>>,
482    },
483    /// Readers can also consult the related terms.
484    SeeAlso {
485        /// The related index terms.
486        targets: Vec<Vec<InlineNode<'a>>>,
487    },
488}
489
490/// An `IndexTerm` represents an index term in a document.
491#[derive(Clone, Debug, PartialEq, Serialize)]
492#[non_exhaustive]
493pub struct IndexTerm<'a> {
494    /// The kind and content of this index term.
495    pub kind: IndexTermKind<'a>,
496    /// The relationship from this entry to other index terms.
497    #[serde(skip_serializing_if = "Option::is_none")]
498    pub relationship: Option<IndexTermRelationship<'a>>,
499    /// Labels captured at index registration, before later substitutions.
500    #[serde(skip_serializing_if = "Option::is_none")]
501    pub(crate) catalog: Option<Box<IndexTerm<'a>>>,
502    /// Consumed after catalog source locations and passthroughs are resolved.
503    #[serde(skip)]
504    pub(crate) catalog_substitutions: Option<crate::model::substitution::SubstitutionPlan>,
505    pub location: Location,
506}
507
508impl<'a> IndexTerm<'a> {
509    pub(crate) fn for_each_label_mut(&mut self, mut visit: impl FnMut(&mut Vec<InlineNode<'a>>)) {
510        match &mut self.kind {
511            IndexTermKind::Flow(term) => visit(term),
512            IndexTermKind::Concealed {
513                term,
514                secondary,
515                tertiary,
516            } => {
517                visit(term);
518                if let Some(secondary) = secondary {
519                    visit(secondary);
520                }
521                if let Some(tertiary) = tertiary {
522                    visit(tertiary);
523                }
524            }
525        }
526        match &mut self.relationship {
527            Some(IndexTermRelationship::See { target }) => visit(target),
528            Some(IndexTermRelationship::SeeAlso { targets }) => {
529                for target in targets {
530                    visit(target);
531                }
532            }
533            None => {}
534        }
535    }
536
537    /// Returns the labels registered in the index catalog.
538    ///
539    /// These can differ from the displayed term when substitutions run after
540    /// index registration. The returned entry is not another occurrence.
541    #[must_use]
542    pub fn catalog_entry(&self) -> &Self {
543        self.catalog.as_deref().unwrap_or(self)
544    }
545
546    /// Returns the primary term.
547    #[must_use]
548    pub fn term(&self) -> &[InlineNode<'a>] {
549        match &self.kind {
550            IndexTermKind::Flow(term) | IndexTermKind::Concealed { term, .. } => term,
551        }
552    }
553
554    /// Returns the secondary term, if any.
555    #[must_use]
556    pub fn secondary(&self) -> Option<&[InlineNode<'a>]> {
557        match &self.kind {
558            IndexTermKind::Flow(_) => None,
559            IndexTermKind::Concealed { secondary, .. } => secondary.as_deref(),
560        }
561    }
562
563    /// Returns the tertiary term, if any.
564    #[must_use]
565    pub fn tertiary(&self) -> Option<&[InlineNode<'a>]> {
566        match &self.kind {
567            IndexTermKind::Flow(_) => None,
568            IndexTermKind::Concealed { tertiary, .. } => tertiary.as_deref(),
569        }
570    }
571
572    /// Returns whether this term is visible in the output.
573    #[must_use]
574    pub fn is_visible(&self) -> bool {
575        matches!(self.kind, IndexTermKind::Flow(_))
576    }
577}
578
579#[cfg(test)]
580mod tests {
581    use crate::{Document, InlineMacro, Options, grammar::walk_document_inline_nodes_mut, parse};
582
583    use super::*;
584
585    #[test]
586    fn signifier_snapshots_are_not_part_of_the_value_contract()
587    -> Result<(), Box<dyn std::error::Error>> {
588        let parsed = parse(
589            include_str!("../../../fixtures/tests/xref_signifier_renumber.adoc"),
590            &Options::default(),
591        )?;
592        let mut document = Document {
593            blocks: parsed.document().blocks.clone(),
594            ..Document::default()
595        };
596        let mut xrefs = Vec::new();
597        walk_document_inline_nodes_mut(&mut document, &mut |inline| {
598            if let InlineNode::Macro(InlineMacro::CrossReference(xref)) = inline {
599                xrefs.push(xref.clone());
600            }
601        });
602        assert_ne!(xrefs, []);
603        for original in xrefs {
604            assert!(original.section_signifiers.is_some());
605            let mut explicit = original.clone();
606            explicit.set_signifier(original.signifier());
607            assert!(explicit.section_signifiers.is_none());
608            assert_eq!(original, explicit);
609            assert_eq!(format!("{original:?}"), format!("{explicit:?}"));
610            assert_eq!(
611                serde_json::to_value(&original)?,
612                serde_json::to_value(&explicit)?
613            );
614        }
615        Ok(())
616    }
617}