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