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 == '.' {
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_section_generate_id() {
180        let inlines: &[InlineNode] = &[InlineNode::PlainText(Plain {
181            content: "This is a b__i__g title.".to_string(),
182            location: Location::default(),
183            escaped: false,
184        })];
185        // metadata has an empty id
186        let metadata = BlockMetadata::default();
187        assert_eq!(
188            Section::generate_id(&metadata, inlines),
189            SafeId::Generated("this_is_a_big_title".to_string())
190        );
191
192        // metadata has a specific id in metadata.id
193        let metadata = BlockMetadata {
194            id: Some(Anchor {
195                id: "custom_id".to_string(),
196                xreflabel: None,
197                location: Location::default(),
198            }),
199            ..Default::default()
200        };
201        assert_eq!(
202            Section::generate_id(&metadata, inlines),
203            SafeId::Explicit("custom_id".to_string())
204        );
205
206        // metadata has anchor in metadata.anchors (from [[id]] or [#id] syntax)
207        let metadata = BlockMetadata {
208            anchors: vec![Anchor {
209                id: "anchor_id".to_string(),
210                xreflabel: None,
211                location: Location::default(),
212            }],
213            ..Default::default()
214        };
215        assert_eq!(
216            Section::generate_id(&metadata, inlines),
217            SafeId::Explicit("anchor_id".to_string())
218        );
219
220        // with multiple anchors, the last one is used (matches asciidoctor behavior)
221        let metadata = BlockMetadata {
222            anchors: vec![
223                Anchor {
224                    id: "first_anchor".to_string(),
225                    xreflabel: None,
226                    location: Location::default(),
227                },
228                Anchor {
229                    id: "last_anchor".to_string(),
230                    xreflabel: None,
231                    location: Location::default(),
232                },
233            ],
234            ..Default::default()
235        };
236        assert_eq!(
237            Section::generate_id(&metadata, inlines),
238            SafeId::Explicit("last_anchor".to_string())
239        );
240
241        // metadata.id takes precedence over metadata.anchors
242        let metadata = BlockMetadata {
243            id: Some(Anchor {
244                id: "from_id".to_string(),
245                xreflabel: None,
246                location: Location::default(),
247            }),
248            anchors: vec![Anchor {
249                id: "from_anchors".to_string(),
250                xreflabel: None,
251                location: Location::default(),
252            }],
253            ..Default::default()
254        };
255        assert_eq!(
256            Section::generate_id(&metadata, inlines),
257            SafeId::Explicit("from_id".to_string())
258        );
259    }
260}