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