acdc_parser/model/
lists.rs

1//! List types for `AsciiDoc` documents.
2
3use serde::{
4    Deserialize, Serialize,
5    de::{self, Deserializer, MapAccess, Visitor},
6    ser::{SerializeMap, Serializer},
7};
8
9use super::Block;
10use super::anchor::Anchor;
11use super::inlines::InlineNode;
12use super::location::Location;
13use super::metadata::BlockMetadata;
14use super::title::Title;
15
16pub type ListLevel = u8;
17
18/// A `ListItemCheckedStatus` represents the checked status of a list item.
19#[derive(Clone, Debug, PartialEq)]
20#[non_exhaustive]
21pub enum ListItemCheckedStatus {
22    Checked,
23    Unchecked,
24}
25
26/// A `ListItem` represents a list item in a document.
27///
28/// List items have principal text (inline content immediately after the marker) and
29/// optionally attached blocks (via continuation or nesting). This matches Asciidoctor's
30/// AST structure where principal text renders as bare `<p>` and attached blocks render
31/// with their full wrapper divs.
32#[derive(Clone, Debug, PartialEq)]
33#[non_exhaustive]
34pub struct ListItem {
35    pub level: ListLevel,
36    pub marker: String,
37    pub checked: Option<ListItemCheckedStatus>,
38    /// Principal text - inline content that appears immediately after the list marker
39    pub principal: Vec<InlineNode>,
40    /// Attached blocks - blocks attached via continuation (+) or nesting
41    pub blocks: Vec<Block>,
42    pub location: Location,
43}
44
45/// A `DescriptionList` represents a description list in a document.
46#[derive(Clone, Debug, PartialEq)]
47#[non_exhaustive]
48pub struct DescriptionList {
49    pub title: Title,
50    pub metadata: BlockMetadata,
51    pub items: Vec<DescriptionListItem>,
52    pub location: Location,
53}
54
55/// An item in a description list (term + description).
56///
57/// # Structure
58///
59/// ```text
60/// term:: principal text    <- term, delimiter, principal_text
61///        description       <- description (blocks)
62/// ```
63///
64/// # Note on Field Names
65///
66/// - `description` is **singular** (not `descriptions`) - it holds the block content
67///   following the term
68/// - `principal_text` is inline content immediately after the delimiter on the same line
69///
70/// ```
71/// # use acdc_parser::DescriptionListItem;
72/// fn has_description(item: &DescriptionListItem) -> bool {
73///     !item.description.is_empty()  // Note: singular 'description'
74/// }
75/// ```
76#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
77#[non_exhaustive]
78pub struct DescriptionListItem {
79    /// Optional anchors (IDs) attached to this item.
80    #[serde(default, skip_serializing_if = "Vec::is_empty")]
81    pub anchors: Vec<Anchor>,
82    /// The term being defined (inline content before the delimiter).
83    #[serde(default, skip_serializing_if = "Vec::is_empty")]
84    pub term: Vec<InlineNode>,
85    /// The delimiter used (`::`, `:::`, `::::`, or `;;`).
86    pub delimiter: String,
87    /// Inline content immediately after the delimiter on the same line.
88    #[serde(default, skip_serializing_if = "Vec::is_empty")]
89    pub principal_text: Vec<InlineNode>,
90    /// Block content providing the description (singular, not plural).
91    pub description: Vec<Block>,
92    pub location: Location,
93}
94
95/// A `UnorderedList` represents an unordered list in a document.
96#[derive(Clone, Debug, PartialEq)]
97#[non_exhaustive]
98pub struct UnorderedList {
99    pub title: Title,
100    pub metadata: BlockMetadata,
101    pub items: Vec<ListItem>,
102    pub marker: String,
103    pub location: Location,
104}
105
106/// An `OrderedList` represents an ordered list in a document.
107#[derive(Clone, Debug, PartialEq)]
108#[non_exhaustive]
109pub struct OrderedList {
110    pub title: Title,
111    pub metadata: BlockMetadata,
112    pub items: Vec<ListItem>,
113    pub marker: String,
114    pub location: Location,
115}
116
117/// A `CalloutList` represents a callout list in a document.
118///
119/// Callout lists are used to annotate code blocks with numbered references.
120#[derive(Clone, Debug, PartialEq)]
121#[non_exhaustive]
122pub struct CalloutList {
123    pub title: Title,
124    pub metadata: BlockMetadata,
125    pub items: Vec<ListItem>,
126    pub location: Location,
127}
128
129// =============================================================================
130// Serialization
131// =============================================================================
132
133macro_rules! impl_list_serialize {
134    ($type:ty, $variant:literal, with_marker) => {
135        impl Serialize for $type {
136            fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
137            where
138                S: Serializer,
139            {
140                let mut state = serializer.serialize_map(None)?;
141                state.serialize_entry("name", "list")?;
142                state.serialize_entry("type", "block")?;
143                state.serialize_entry("variant", $variant)?;
144                state.serialize_entry("marker", &self.marker)?;
145                if !self.title.is_empty() {
146                    state.serialize_entry("title", &self.title)?;
147                }
148                if !self.metadata.is_default() {
149                    state.serialize_entry("metadata", &self.metadata)?;
150                }
151                state.serialize_entry("items", &self.items)?;
152                state.serialize_entry("location", &self.location)?;
153                state.end()
154            }
155        }
156    };
157    ($type:ty, $variant:literal) => {
158        impl Serialize for $type {
159            fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
160            where
161                S: Serializer,
162            {
163                let mut state = serializer.serialize_map(None)?;
164                state.serialize_entry("name", "list")?;
165                state.serialize_entry("type", "block")?;
166                state.serialize_entry("variant", $variant)?;
167                if !self.title.is_empty() {
168                    state.serialize_entry("title", &self.title)?;
169                }
170                if !self.metadata.is_default() {
171                    state.serialize_entry("metadata", &self.metadata)?;
172                }
173                state.serialize_entry("items", &self.items)?;
174                state.serialize_entry("location", &self.location)?;
175                state.end()
176            }
177        }
178    };
179}
180
181impl_list_serialize!(UnorderedList, "unordered", with_marker);
182impl_list_serialize!(OrderedList, "ordered", with_marker);
183impl_list_serialize!(CalloutList, "callout");
184
185impl Serialize for DescriptionList {
186    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
187    where
188        S: Serializer,
189    {
190        let mut state = serializer.serialize_map(None)?;
191        state.serialize_entry("name", "dlist")?;
192        state.serialize_entry("type", "block")?;
193        if !self.title.is_empty() {
194            state.serialize_entry("title", &self.title)?;
195        }
196        if !self.metadata.is_default() {
197            state.serialize_entry("metadata", &self.metadata)?;
198        }
199        state.serialize_entry("items", &self.items)?;
200        state.serialize_entry("location", &self.location)?;
201        state.end()
202    }
203}
204
205impl Serialize for ListItem {
206    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
207    where
208        S: Serializer,
209    {
210        let mut state = serializer.serialize_map(None)?;
211        state.serialize_entry("name", "listItem")?;
212        state.serialize_entry("type", "block")?;
213        state.serialize_entry("marker", &self.marker)?;
214        if let Some(checked) = &self.checked {
215            state.serialize_entry("checked", checked)?;
216        }
217        // The TCK doesn't contain level information for list items, so we don't serialize
218        // it.
219        //
220        // Uncomment the line below if level information is added in the future.
221        //
222        // state.serialize_entry("level", &self.level)?;
223        state.serialize_entry("principal", &self.principal)?;
224        if !self.blocks.is_empty() {
225            state.serialize_entry("blocks", &self.blocks)?;
226        }
227        state.serialize_entry("location", &self.location)?;
228        state.end()
229    }
230}
231
232impl Serialize for ListItemCheckedStatus {
233    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
234    where
235        S: Serializer,
236    {
237        match &self {
238            ListItemCheckedStatus::Checked => serializer.serialize_bool(true),
239            ListItemCheckedStatus::Unchecked => serializer.serialize_bool(false),
240        }
241    }
242}
243
244// =============================================================================
245// Deserialization
246// =============================================================================
247
248impl<'de> Deserialize<'de> for ListItem {
249    fn deserialize<D>(deserializer: D) -> Result<ListItem, D::Error>
250    where
251        D: Deserializer<'de>,
252    {
253        struct ListItemVisitor;
254
255        impl<'de> Visitor<'de> for ListItemVisitor {
256            type Value = ListItem;
257
258            fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
259                formatter.write_str("a struct representing ListItem")
260            }
261
262            fn visit_map<V>(self, mut map: V) -> Result<ListItem, V::Error>
263            where
264                V: MapAccess<'de>,
265            {
266                let mut my_principal = None;
267                let mut my_blocks = None;
268                let mut my_checked = None;
269                let mut my_location = None;
270                let mut my_marker = None;
271
272                while let Some(key) = map.next_key::<String>()? {
273                    match key.as_str() {
274                        "principal" => {
275                            if my_principal.is_some() {
276                                return Err(de::Error::duplicate_field("principal"));
277                            }
278                            my_principal = Some(map.next_value()?);
279                        }
280                        "blocks" => {
281                            if my_blocks.is_some() {
282                                return Err(de::Error::duplicate_field("blocks"));
283                            }
284                            my_blocks = Some(map.next_value()?);
285                        }
286                        "marker" => {
287                            if my_marker.is_some() {
288                                return Err(de::Error::duplicate_field("marker"));
289                            }
290                            my_marker = Some(map.next_value::<String>()?);
291                        }
292                        "location" => {
293                            if my_location.is_some() {
294                                return Err(de::Error::duplicate_field("location"));
295                            }
296                            my_location = Some(map.next_value()?);
297                        }
298                        "checked" => {
299                            if my_checked.is_some() {
300                                return Err(de::Error::duplicate_field("checked"));
301                            }
302                            my_checked = Some(map.next_value::<bool>()?);
303                        }
304                        _ => {
305                            tracing::debug!(?key, "ignoring unexpected field in ListItem");
306                            // Ignore any other fields
307                            let _ = map.next_value::<de::IgnoredAny>()?;
308                        }
309                    }
310                }
311                let marker = my_marker.ok_or_else(|| de::Error::missing_field("marker"))?;
312                let principal =
313                    my_principal.ok_or_else(|| de::Error::missing_field("principal"))?;
314                let blocks = my_blocks.unwrap_or_default();
315                let level =
316                    ListLevel::try_from(ListItem::parse_depth_from_marker(&marker).unwrap_or(1))
317                        .map_err(|e| {
318                            de::Error::custom(format!("invalid list item level from marker: {e}",))
319                        })?;
320                let checked = my_checked.map(|c| {
321                    if c {
322                        ListItemCheckedStatus::Checked
323                    } else {
324                        ListItemCheckedStatus::Unchecked
325                    }
326                });
327                Ok(ListItem {
328                    level,
329                    marker,
330                    location: my_location.ok_or_else(|| de::Error::missing_field("location"))?,
331                    principal,
332                    blocks,
333                    checked,
334                })
335            }
336        }
337        deserializer.deserialize_map(ListItemVisitor)
338    }
339}
340
341impl<'de> Deserialize<'de> for ListItemCheckedStatus {
342    fn deserialize<D>(deserializer: D) -> Result<ListItemCheckedStatus, D::Error>
343    where
344        D: serde::Deserializer<'de>,
345    {
346        struct ListItemCheckedStatusVisitor;
347
348        impl Visitor<'_> for ListItemCheckedStatusVisitor {
349            type Value = ListItemCheckedStatus;
350
351            fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
352                formatter.write_str("a boolean representing checked status")
353            }
354
355            fn visit_bool<E>(self, v: bool) -> Result<Self::Value, E>
356            where
357                E: de::Error,
358            {
359                if v {
360                    Ok(ListItemCheckedStatus::Checked)
361                } else {
362                    Ok(ListItemCheckedStatus::Unchecked)
363                }
364            }
365        }
366
367        deserializer.deserialize_bool(ListItemCheckedStatusVisitor)
368    }
369}