Skip to main content

acdc_parser/model/inlines/
mod.rs

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