Skip to main content

acdc_parser/model/inlines/
mod.rs

1use serde::{
2    Serialize,
3    ser::{Error as _, SerializeMap, Serializer},
4};
5
6pub(crate) mod converter;
7mod macros;
8mod text;
9
10pub use converter::inlines_to_string;
11pub use macros::*;
12pub use text::*;
13
14use crate::{Anchor, Image, Location, model::Locateable};
15
16/// An `InlineNode` represents an inline node in a document.
17///
18/// An inline node is a structural element in a document that can contain other inline
19/// nodes and are only valid within a paragraph (a leaf).
20#[non_exhaustive]
21#[derive(Clone, Debug, PartialEq)]
22pub enum InlineNode {
23    // This is just "normal" text
24    PlainText(Plain),
25    // This is raw text only found in Delimited Pass blocks
26    RawText(Raw),
27    // This is verbatim text found in Delimited Literal and Listing blocks
28    VerbatimText(Verbatim),
29    BoldText(Bold),
30    ItalicText(Italic),
31    MonospaceText(Monospace),
32    HighlightText(Highlight),
33    SubscriptText(Subscript),
34    SuperscriptText(Superscript),
35    CurvedQuotationText(CurvedQuotation),
36    CurvedApostropheText(CurvedApostrophe),
37    StandaloneCurvedApostrophe(StandaloneCurvedApostrophe),
38    LineBreak(LineBreak),
39    InlineAnchor(Anchor),
40    Macro(InlineMacro),
41    /// Callout reference marker in verbatim content: `<1>`, `<.>`, etc.
42    CalloutRef(CalloutRef),
43}
44
45impl InlineNode {
46    /// Returns the source location of this inline node.
47    #[must_use]
48    pub fn location(&self) -> &Location {
49        <Self as Locateable>::location(self)
50    }
51}
52
53impl Locateable for InlineNode {
54    fn location(&self) -> &Location {
55        match self {
56            InlineNode::PlainText(t) => &t.location,
57            InlineNode::RawText(t) => &t.location,
58            InlineNode::VerbatimText(t) => &t.location,
59            InlineNode::BoldText(t) => &t.location,
60            InlineNode::ItalicText(t) => &t.location,
61            InlineNode::MonospaceText(t) => &t.location,
62            InlineNode::HighlightText(t) => &t.location,
63            InlineNode::SubscriptText(t) => &t.location,
64            InlineNode::SuperscriptText(t) => &t.location,
65            InlineNode::CurvedQuotationText(t) => &t.location,
66            InlineNode::CurvedApostropheText(t) => &t.location,
67            InlineNode::StandaloneCurvedApostrophe(t) => &t.location,
68            InlineNode::LineBreak(l) => &l.location,
69            InlineNode::InlineAnchor(a) => &a.location,
70            InlineNode::Macro(m) => m.location(),
71            InlineNode::CalloutRef(c) => &c.location,
72        }
73    }
74}
75impl InlineMacro {
76    /// Returns the source location of this inline macro.
77    #[must_use]
78    pub fn location(&self) -> &Location {
79        <Self as Locateable>::location(self)
80    }
81}
82
83impl Locateable for InlineMacro {
84    fn location(&self) -> &Location {
85        match self {
86            Self::Footnote(f) => &f.location,
87            Self::Icon(i) => &i.location,
88            Self::Image(img) => &img.location,
89            Self::Keyboard(k) => &k.location,
90            Self::Button(b) => &b.location,
91            Self::Menu(m) => &m.location,
92            Self::Url(u) => &u.location,
93            Self::Mailto(m) => &m.location,
94            Self::Link(l) => &l.location,
95            Self::Autolink(a) => &a.location,
96            Self::CrossReference(x) => &x.location,
97            Self::Pass(p) => &p.location,
98            Self::Stem(s) => &s.location,
99            Self::IndexTerm(i) => &i.location,
100        }
101    }
102}
103
104/// An inline macro - a functional element that produces inline content.
105///
106/// Unlike a struct with `name`/`target`/`attributes` fields, `InlineMacro` is an **enum**
107/// where each variant represents a specific macro type with its own specialized fields.
108///
109/// # Variants Overview
110///
111/// | Variant | `AsciiDoc` Syntax | Description |
112/// |---------|-----------------|-------------|
113/// | `Link` | `link:url[text]` | Explicit link with optional text |
114/// | `Url` | `\https://...` or `link:` | URL reference |
115/// | `Mailto` | `mailto:addr[text]` | Email link |
116/// | `Autolink` | `<\https://...>` | Auto-detected URL |
117/// | `CrossReference` | `<<id>>` or `xref:id[]` | Internal document reference |
118/// | `Image` | `image:file.png[alt]` | Inline image |
119/// | `Icon` | `icon:name[]` | Icon reference (font or image) |
120/// | `Footnote` | `footnote:[text]` | Footnote reference |
121/// | `Keyboard` | `kbd:[Ctrl+C]` | Keyboard shortcut |
122/// | `Button` | `btn:[OK]` | UI button label |
123/// | `Menu` | `menu:File[Save]` | Menu navigation path |
124/// | `Pass` | `pass:[content]` | Passthrough (no processing) |
125/// | `Stem` | `stem:[formula]` | Math notation |
126/// | `IndexTerm` | `((term))` or `(((term)))` | Index term (visible or hidden) |
127///
128/// # Example
129///
130/// ```
131/// # use acdc_parser::{InlineMacro, InlineNode};
132/// fn extract_link_target(node: &InlineNode) -> Option<String> {
133///     match node {
134///         InlineNode::Macro(InlineMacro::Link(link)) => Some(link.target.to_string()),
135///         InlineNode::Macro(InlineMacro::Url(url)) => Some(url.target.to_string()),
136///         InlineNode::Macro(InlineMacro::CrossReference(xref)) => Some(xref.target.clone()),
137///         _ => None,
138///     }
139/// }
140/// ```
141#[non_exhaustive]
142#[derive(Clone, Debug, PartialEq, Serialize)]
143pub enum InlineMacro {
144    /// Footnote reference: `footnote:[content]` or `footnote:id[content]`
145    Footnote(Footnote),
146    /// Icon macro: `icon:name[attributes]`
147    Icon(Icon),
148    /// Inline image: `image:path[alt,width,height]`
149    Image(Box<Image>),
150    /// Keyboard shortcut: `kbd:[Ctrl+C]`
151    Keyboard(Keyboard),
152    /// UI button: `btn:[Label]`
153    Button(Button),
154    /// Menu path: `menu:TopLevel[Item > Subitem]`
155    Menu(Menu),
156    /// URL with optional text: parsed from `link:` macro or bare URLs
157    Url(Url),
158    /// Explicit link macro: `link:target[text]`
159    Link(Link),
160    /// Email link: `mailto:address[text]`
161    Mailto(Mailto),
162    /// Auto-detected URL: `<\https://example.com>`
163    Autolink(Autolink),
164    /// Cross-reference: `<<id,text>>` or `xref:id[text]`
165    CrossReference(CrossReference),
166    /// Inline passthrough: `pass:[content]` - not serialized to ASG
167    Pass(Pass),
168    /// Inline math: `stem:[formula]` or `latexmath:[...]` / `asciimath:[...]`
169    Stem(Stem),
170    /// Index term: `((term))` (visible) or `(((term)))` (hidden)
171    IndexTerm(IndexTerm),
172}
173
174/// Macro to serialize inline format types (Bold, Italic, Monospace, etc.)
175/// All these types share identical structure and serialization logic.
176macro_rules! serialize_inline_format {
177    ($map:expr, $value:expr, $variant:literal) => {{
178        $map.serialize_entry("name", "span")?;
179        $map.serialize_entry("type", "inline")?;
180        $map.serialize_entry("variant", $variant)?;
181        $map.serialize_entry("form", &$value.form)?;
182        if let Some(role) = &$value.role {
183            $map.serialize_entry("role", role)?;
184        }
185        if let Some(id) = &$value.id {
186            $map.serialize_entry("id", id)?;
187        }
188        $map.serialize_entry("inlines", &$value.content)?;
189        $map.serialize_entry("location", &$value.location)?;
190    }};
191}
192
193impl Serialize for InlineNode {
194    #[allow(clippy::too_many_lines)]
195    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
196    where
197        S: Serializer,
198    {
199        let mut map = serializer.serialize_map(None)?;
200
201        match self {
202            InlineNode::PlainText(plain) => {
203                map.serialize_entry("name", "text")?;
204                map.serialize_entry("type", "string")?;
205                map.serialize_entry("value", &plain.content)?;
206                map.serialize_entry("location", &plain.location)?;
207            }
208            InlineNode::RawText(raw) => {
209                map.serialize_entry("name", "raw")?;
210                map.serialize_entry("type", "string")?;
211                map.serialize_entry("value", &raw.content)?;
212                map.serialize_entry("location", &raw.location)?;
213            }
214            InlineNode::VerbatimText(verbatim) => {
215                // We use "text" here to make sure the TCK passes, even though this is raw
216                // text.
217                map.serialize_entry("name", "text")?;
218                map.serialize_entry("type", "string")?;
219                map.serialize_entry("value", &verbatim.content)?;
220                map.serialize_entry("location", &verbatim.location)?;
221            }
222            InlineNode::HighlightText(highlight) => {
223                serialize_inline_format!(map, highlight, "mark");
224            }
225            InlineNode::ItalicText(italic) => {
226                serialize_inline_format!(map, italic, "emphasis");
227            }
228            InlineNode::BoldText(bold) => {
229                serialize_inline_format!(map, bold, "strong");
230            }
231            InlineNode::MonospaceText(monospace) => {
232                serialize_inline_format!(map, monospace, "code");
233            }
234            InlineNode::SubscriptText(subscript) => {
235                serialize_inline_format!(map, subscript, "subscript");
236            }
237            InlineNode::SuperscriptText(superscript) => {
238                serialize_inline_format!(map, superscript, "superscript");
239            }
240            InlineNode::CurvedQuotationText(curved_quotation) => {
241                serialize_inline_format!(map, curved_quotation, "curved_quotation");
242            }
243            InlineNode::CurvedApostropheText(curved_apostrophe) => {
244                serialize_inline_format!(map, curved_apostrophe, "curved_apostrophe");
245            }
246            InlineNode::StandaloneCurvedApostrophe(standalone) => {
247                map.serialize_entry("name", "curved_apostrophe")?;
248                map.serialize_entry("type", "string")?;
249                map.serialize_entry("location", &standalone.location)?;
250            }
251            InlineNode::LineBreak(line_break) => {
252                map.serialize_entry("name", "break")?;
253                map.serialize_entry("type", "inline")?;
254                map.serialize_entry("location", &line_break.location)?;
255            }
256            InlineNode::InlineAnchor(anchor) => {
257                map.serialize_entry("name", "anchor")?;
258                map.serialize_entry("type", "inline")?;
259                map.serialize_entry("id", &anchor.id)?;
260                if let Some(xreflabel) = &anchor.xreflabel {
261                    map.serialize_entry("xreflabel", xreflabel)?;
262                }
263                map.serialize_entry("location", &anchor.location)?;
264            }
265            InlineNode::Macro(macro_node) => {
266                serialize_inline_macro::<S>(macro_node, &mut map)?;
267            }
268            InlineNode::CalloutRef(callout_ref) => {
269                map.serialize_entry("name", "callout_reference")?;
270                map.serialize_entry("type", "inline")?;
271                map.serialize_entry("variant", &callout_ref.kind)?;
272                map.serialize_entry("number", &callout_ref.number)?;
273                map.serialize_entry("location", &callout_ref.location)?;
274            }
275        }
276        map.end()
277    }
278}
279
280fn serialize_inline_macro<S>(
281    macro_node: &InlineMacro,
282    map: &mut S::SerializeMap,
283) -> Result<(), S::Error>
284where
285    S: Serializer,
286{
287    match macro_node {
288        InlineMacro::Footnote(f) => serialize_footnote::<S>(f, map),
289        InlineMacro::Icon(i) => serialize_icon::<S>(i, map),
290        InlineMacro::Image(i) => serialize_image::<S>(i, map),
291        InlineMacro::Keyboard(k) => serialize_keyboard::<S>(k, map),
292        InlineMacro::Button(b) => serialize_button::<S>(b, map),
293        InlineMacro::Menu(m) => serialize_menu::<S>(m, map),
294        InlineMacro::Url(u) => serialize_url::<S>(u, map),
295        InlineMacro::Mailto(m) => serialize_mailto::<S>(m, map),
296        InlineMacro::Link(l) => serialize_link::<S>(l, map),
297        InlineMacro::Autolink(a) => serialize_autolink::<S>(a, map),
298        InlineMacro::CrossReference(x) => serialize_xref::<S>(x, map),
299        InlineMacro::Stem(s) => serialize_stem::<S>(s, map),
300        InlineMacro::IndexTerm(i) => serialize_indexterm::<S>(i, map),
301        InlineMacro::Pass(_) => Err(S::Error::custom(
302            "inline passthrough macros are not part of the ASG specification and cannot be serialized",
303        )),
304    }
305}
306
307fn serialize_footnote<S>(f: &Footnote, map: &mut S::SerializeMap) -> Result<(), S::Error>
308where
309    S: Serializer,
310{
311    map.serialize_entry("name", "footnote")?;
312    map.serialize_entry("type", "inline")?;
313    map.serialize_entry("id", &f.id)?;
314    map.serialize_entry("inlines", &f.content)?;
315    map.serialize_entry("location", &f.location)
316}
317
318fn serialize_icon<S>(i: &Icon, map: &mut S::SerializeMap) -> Result<(), S::Error>
319where
320    S: Serializer,
321{
322    map.serialize_entry("name", "icon")?;
323    map.serialize_entry("type", "inline")?;
324    map.serialize_entry("target", &i.target)?;
325    if !i.attributes.is_empty() {
326        map.serialize_entry("attributes", &i.attributes)?;
327    }
328    map.serialize_entry("location", &i.location)
329}
330
331fn serialize_image<S>(i: &Image, map: &mut S::SerializeMap) -> Result<(), S::Error>
332where
333    S: Serializer,
334{
335    map.serialize_entry("name", "image")?;
336    map.serialize_entry("type", "inline")?;
337    map.serialize_entry("title", &i.title)?;
338    map.serialize_entry("target", &i.source)?;
339    map.serialize_entry("location", &i.location)
340}
341
342fn serialize_keyboard<S>(k: &Keyboard, map: &mut S::SerializeMap) -> Result<(), S::Error>
343where
344    S: Serializer,
345{
346    map.serialize_entry("name", "keyboard")?;
347    map.serialize_entry("type", "inline")?;
348    map.serialize_entry("keys", &k.keys)?;
349    map.serialize_entry("location", &k.location)
350}
351
352fn serialize_button<S>(b: &Button, map: &mut S::SerializeMap) -> Result<(), S::Error>
353where
354    S: Serializer,
355{
356    map.serialize_entry("name", "button")?;
357    map.serialize_entry("type", "inline")?;
358    map.serialize_entry("label", &b.label)?;
359    map.serialize_entry("location", &b.location)
360}
361
362fn serialize_menu<S>(m: &Menu, map: &mut S::SerializeMap) -> Result<(), S::Error>
363where
364    S: Serializer,
365{
366    map.serialize_entry("name", "menu")?;
367    map.serialize_entry("type", "inline")?;
368    map.serialize_entry("target", &m.target)?;
369    if !m.items.is_empty() {
370        map.serialize_entry("items", &m.items)?;
371    }
372    map.serialize_entry("location", &m.location)
373}
374
375fn serialize_url<S>(u: &Url, map: &mut S::SerializeMap) -> Result<(), S::Error>
376where
377    S: Serializer,
378{
379    map.serialize_entry("name", "ref")?;
380    map.serialize_entry("type", "inline")?;
381    map.serialize_entry("variant", "link")?;
382    map.serialize_entry("target", &u.target)?;
383    map.serialize_entry("location", &u.location)?;
384    map.serialize_entry("attributes", &u.attributes)
385}
386
387fn serialize_mailto<S>(m: &Mailto, map: &mut S::SerializeMap) -> Result<(), S::Error>
388where
389    S: Serializer,
390{
391    map.serialize_entry("name", "ref")?;
392    map.serialize_entry("type", "inline")?;
393    map.serialize_entry("variant", "mailto")?;
394    map.serialize_entry("target", &m.target)?;
395    map.serialize_entry("location", &m.location)?;
396    map.serialize_entry("attributes", &m.attributes)
397}
398
399fn serialize_link<S>(l: &Link, map: &mut S::SerializeMap) -> Result<(), S::Error>
400where
401    S: Serializer,
402{
403    map.serialize_entry("name", "ref")?;
404    map.serialize_entry("type", "inline")?;
405    map.serialize_entry("variant", "link")?;
406    map.serialize_entry("target", &l.target)?;
407    map.serialize_entry("location", &l.location)?;
408    map.serialize_entry("attributes", &l.attributes)
409}
410
411fn serialize_autolink<S>(a: &Autolink, map: &mut S::SerializeMap) -> Result<(), S::Error>
412where
413    S: Serializer,
414{
415    map.serialize_entry("name", "ref")?;
416    map.serialize_entry("type", "inline")?;
417    map.serialize_entry("variant", "autolink")?;
418    map.serialize_entry("target", &a.url)?;
419    map.serialize_entry("location", &a.location)
420}
421
422fn serialize_xref<S>(x: &CrossReference, map: &mut S::SerializeMap) -> Result<(), S::Error>
423where
424    S: Serializer,
425{
426    map.serialize_entry("name", "xref")?;
427    map.serialize_entry("type", "inline")?;
428    map.serialize_entry("target", &x.target)?;
429    if !x.text.is_empty() {
430        map.serialize_entry("inlines", &x.text)?;
431    }
432    map.serialize_entry("location", &x.location)
433}
434
435fn serialize_stem<S>(s: &Stem, map: &mut S::SerializeMap) -> Result<(), S::Error>
436where
437    S: Serializer,
438{
439    map.serialize_entry("name", "stem")?;
440    map.serialize_entry("type", "inline")?;
441    map.serialize_entry("content", &s.content)?;
442    map.serialize_entry("notation", &s.notation)?;
443    map.serialize_entry("location", &s.location)
444}
445
446fn serialize_indexterm<S>(i: &IndexTerm, map: &mut S::SerializeMap) -> Result<(), S::Error>
447where
448    S: Serializer,
449{
450    map.serialize_entry("name", "indexterm")?;
451    map.serialize_entry("type", "inline")?;
452    map.serialize_entry("term", i.term())?;
453    if let Some(secondary) = i.secondary() {
454        map.serialize_entry("secondary", secondary)?;
455    }
456    if let Some(tertiary) = i.tertiary() {
457        map.serialize_entry("tertiary", tertiary)?;
458    }
459    map.serialize_entry("visible", &i.is_visible())?;
460    map.serialize_entry("location", &i.location)
461}