acdc_parser/model/
section.rs

1use std::fmt::Display;
2
3use serde::ser::{Serialize, SerializeMap, Serializer};
4
5use crate::{Block, BlockMetadata, InlineNode, Location, model::inlines::converter};
6
7use super::title::Title;
8
9/// A `SectionLevel` represents a section depth in a document.
10pub type SectionLevel = u8;
11
12/// A `Section` represents a section in a document.
13#[derive(Clone, Debug, PartialEq)]
14#[non_exhaustive]
15pub struct Section {
16    pub metadata: BlockMetadata,
17    pub title: Title,
18    pub level: SectionLevel,
19    pub content: Vec<Block>,
20    pub location: Location,
21}
22
23impl Section {
24    /// Create a new section with the given title, level, content, and location.
25    #[must_use]
26    pub fn new(title: Title, level: SectionLevel, content: Vec<Block>, location: Location) -> Self {
27        Self {
28            metadata: BlockMetadata::default(),
29            title,
30            level,
31            content,
32            location,
33        }
34    }
35
36    /// Set the metadata.
37    #[must_use]
38    pub fn with_metadata(mut self, metadata: BlockMetadata) -> Self {
39        self.metadata = metadata;
40        self
41    }
42}
43
44/// A `SafeId` represents a sanitised ID.
45#[derive(Clone, Debug, PartialEq)]
46#[non_exhaustive]
47pub enum SafeId {
48    Generated(String),
49    Explicit(String),
50}
51
52impl Display for SafeId {
53    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
54        match self {
55            SafeId::Generated(id) => write!(f, "_{id}"),
56            SafeId::Explicit(id) => write!(f, "{id}"),
57        }
58    }
59}
60
61impl Section {
62    fn id_from_title(title: &[InlineNode]) -> String {
63        // Generate ID from title
64        let title_text = converter::inlines_to_string(title);
65        let mut id = title_text
66            .to_lowercase()
67            .chars()
68            .filter_map(|c| {
69                if c.is_alphanumeric() {
70                    Some(c)
71                } else if c.is_whitespace() || c == '-' || c == '.' {
72                    Some('_')
73                } else {
74                    None
75                }
76            })
77            .collect::<String>();
78
79        // Trim trailing underscores
80        id = id.trim_end_matches('_').to_string();
81
82        // Collapse consecutive underscores into single underscore
83        //
84        // We'll build a (String, bool) tuple
85        // The bool tracks: "was the last char an underscore?"
86        let (collapsed, _) = id.chars().fold(
87            (String::with_capacity(id.len()), false), // (new_string, last_was_underscore)
88            |(mut acc_string, last_was_underscore), current_char| {
89                if current_char == '_' {
90                    if !last_was_underscore {
91                        acc_string.push('_'); // Only add if last char wasn't one
92                    }
93                    (acc_string, true) // Mark last_was_underscore as true
94                } else {
95                    acc_string.push(current_char);
96                    (acc_string, false) // Mark last_was_underscore as false
97                }
98            },
99        );
100        collapsed
101    }
102
103    /// Generate a section ID based on its title and metadata.
104    ///
105    /// This function checks for explicit IDs in the following order:
106    /// 1. `metadata.id` - from attribute list syntax like `[id=foo]`
107    /// 2. `metadata.anchors` - from anchor syntax like `[[foo]]` or `[#foo]`
108    ///
109    /// If no explicit ID is found, it generates one from the title by converting
110    /// to lowercase, replacing spaces and hyphens with underscores, and removing
111    /// non-alphanumeric characters.
112    #[must_use]
113    pub fn generate_id(metadata: &BlockMetadata, title: &[InlineNode]) -> SafeId {
114        // Check explicit ID from attribute list first
115        if let Some(anchor) = &metadata.id {
116            return SafeId::Explicit(anchor.id.clone());
117        }
118        // Check last anchor from block metadata lines (e.g., [[id]] or [#id])
119        // asciidoctor uses the last anchor when multiple are present
120        if let Some(anchor) = metadata.anchors.last() {
121            return SafeId::Explicit(anchor.id.clone());
122        }
123        // Fall back to auto-generated ID from title
124        let id = Self::id_from_title(title);
125        SafeId::Generated(id)
126    }
127}
128
129impl Serialize for Section {
130    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
131    where
132        S: Serializer,
133    {
134        let mut state = serializer.serialize_map(None)?;
135        state.serialize_entry("name", "section")?;
136        state.serialize_entry("type", "block")?;
137        state.serialize_entry("title", &self.title)?;
138        state.serialize_entry("level", &self.level)?;
139        if !self.metadata.is_default() {
140            state.serialize_entry("metadata", &self.metadata)?;
141        }
142        if !self.content.is_empty() {
143            state.serialize_entry("blocks", &self.content)?;
144        }
145        state.serialize_entry("location", &self.location)?;
146        state.end()
147    }
148}
149
150#[cfg(test)]
151mod tests {
152    use crate::{Anchor, Plain};
153
154    use super::*;
155
156    #[test]
157    fn test_id_from_title() {
158        let inlines: &[InlineNode] = &[InlineNode::PlainText(Plain {
159            content: "This is a title.".to_string(),
160            location: Location::default(),
161        })];
162        assert_eq!(
163            Section::id_from_title(inlines),
164            "this_is_a_title".to_string()
165        );
166        let inlines: &[InlineNode] = &[InlineNode::PlainText(Plain {
167            content: "This is a----title.".to_string(),
168            location: Location::default(),
169        })];
170        assert_eq!(
171            Section::id_from_title(inlines),
172            "this_is_a_title".to_string()
173        );
174    }
175
176    #[test]
177    fn test_section_generate_id() {
178        let inlines: &[InlineNode] = &[InlineNode::PlainText(Plain {
179            content: "This is a b__i__g title.".to_string(),
180            location: Location::default(),
181        })];
182        // metadata has an empty id
183        let metadata = BlockMetadata::default();
184        assert_eq!(
185            Section::generate_id(&metadata, inlines),
186            SafeId::Generated("this_is_a_big_title".to_string())
187        );
188
189        // metadata has a specific id in metadata.id
190        let metadata = BlockMetadata {
191            id: Some(Anchor {
192                id: "custom_id".to_string(),
193                xreflabel: None,
194                location: Location::default(),
195            }),
196            ..Default::default()
197        };
198        assert_eq!(
199            Section::generate_id(&metadata, inlines),
200            SafeId::Explicit("custom_id".to_string())
201        );
202
203        // metadata has anchor in metadata.anchors (from [[id]] or [#id] syntax)
204        let metadata = BlockMetadata {
205            anchors: vec![Anchor {
206                id: "anchor_id".to_string(),
207                xreflabel: None,
208                location: Location::default(),
209            }],
210            ..Default::default()
211        };
212        assert_eq!(
213            Section::generate_id(&metadata, inlines),
214            SafeId::Explicit("anchor_id".to_string())
215        );
216
217        // with multiple anchors, the last one is used (matches asciidoctor behavior)
218        let metadata = BlockMetadata {
219            anchors: vec![
220                Anchor {
221                    id: "first_anchor".to_string(),
222                    xreflabel: None,
223                    location: Location::default(),
224                },
225                Anchor {
226                    id: "last_anchor".to_string(),
227                    xreflabel: None,
228                    location: Location::default(),
229                },
230            ],
231            ..Default::default()
232        };
233        assert_eq!(
234            Section::generate_id(&metadata, inlines),
235            SafeId::Explicit("last_anchor".to_string())
236        );
237
238        // metadata.id takes precedence over metadata.anchors
239        let metadata = BlockMetadata {
240            id: Some(Anchor {
241                id: "from_id".to_string(),
242                xreflabel: None,
243                location: Location::default(),
244            }),
245            anchors: vec![Anchor {
246                id: "from_anchors".to_string(),
247                xreflabel: None,
248                location: Location::default(),
249            }],
250            ..Default::default()
251        };
252        assert_eq!(
253            Section::generate_id(&metadata, inlines),
254            SafeId::Explicit("from_id".to_string())
255        );
256    }
257}