Skip to main content

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 == '.' || 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            escaped: false,
162        })];
163        assert_eq!(
164            Section::id_from_title(inlines),
165            "this_is_a_title".to_string()
166        );
167        let inlines: &[InlineNode] = &[InlineNode::PlainText(Plain {
168            content: "This is a----title.".to_string(),
169            location: Location::default(),
170            escaped: false,
171        })];
172        assert_eq!(
173            Section::id_from_title(inlines),
174            "this_is_a_title".to_string()
175        );
176    }
177
178    #[test]
179    fn test_id_from_title_preserves_underscores() {
180        // Underscores within words should be preserved (matching asciidoctor behavior)
181        let inlines: &[InlineNode] = &[InlineNode::PlainText(Plain {
182            content: "CHART_BOT".to_string(),
183            location: Location::default(),
184            escaped: false,
185        })];
186        assert_eq!(Section::id_from_title(inlines), "chart_bot".to_string());
187        let inlines: &[InlineNode] = &[InlineNode::PlainText(Plain {
188            content: "haiku_robot".to_string(),
189            location: Location::default(),
190            escaped: false,
191        })];
192        assert_eq!(Section::id_from_title(inlines), "haiku_robot".to_string());
193        let inlines: &[InlineNode] = &[InlineNode::PlainText(Plain {
194            content: "meme_transcriber".to_string(),
195            location: Location::default(),
196            escaped: false,
197        })];
198        assert_eq!(
199            Section::id_from_title(inlines),
200            "meme_transcriber".to_string()
201        );
202    }
203
204    #[test]
205    fn test_section_generate_id() {
206        let inlines: &[InlineNode] = &[InlineNode::PlainText(Plain {
207            content: "This is a b__i__g title.".to_string(),
208            location: Location::default(),
209            escaped: false,
210        })];
211        // metadata has an empty id
212        let metadata = BlockMetadata::default();
213        assert_eq!(
214            Section::generate_id(&metadata, inlines),
215            SafeId::Generated("this_is_a_b_i_g_title".to_string())
216        );
217
218        // metadata has a specific id in metadata.id
219        let metadata = BlockMetadata {
220            id: Some(Anchor {
221                id: "custom_id".to_string(),
222                xreflabel: None,
223                location: Location::default(),
224            }),
225            ..Default::default()
226        };
227        assert_eq!(
228            Section::generate_id(&metadata, inlines),
229            SafeId::Explicit("custom_id".to_string())
230        );
231
232        // metadata has anchor in metadata.anchors (from [[id]] or [#id] syntax)
233        let metadata = BlockMetadata {
234            anchors: vec![Anchor {
235                id: "anchor_id".to_string(),
236                xreflabel: None,
237                location: Location::default(),
238            }],
239            ..Default::default()
240        };
241        assert_eq!(
242            Section::generate_id(&metadata, inlines),
243            SafeId::Explicit("anchor_id".to_string())
244        );
245
246        // with multiple anchors, the last one is used (matches asciidoctor behavior)
247        let metadata = BlockMetadata {
248            anchors: vec![
249                Anchor {
250                    id: "first_anchor".to_string(),
251                    xreflabel: None,
252                    location: Location::default(),
253                },
254                Anchor {
255                    id: "last_anchor".to_string(),
256                    xreflabel: None,
257                    location: Location::default(),
258                },
259            ],
260            ..Default::default()
261        };
262        assert_eq!(
263            Section::generate_id(&metadata, inlines),
264            SafeId::Explicit("last_anchor".to_string())
265        );
266
267        // metadata.id takes precedence over metadata.anchors
268        let metadata = BlockMetadata {
269            id: Some(Anchor {
270                id: "from_id".to_string(),
271                xreflabel: None,
272                location: Location::default(),
273            }),
274            anchors: vec![Anchor {
275                id: "from_anchors".to_string(),
276                xreflabel: None,
277                location: Location::default(),
278            }],
279            ..Default::default()
280        };
281        assert_eq!(
282            Section::generate_id(&metadata, inlines),
283            SafeId::Explicit("from_id".to_string())
284        );
285    }
286}