Skip to main content

acdc_parser/model/
section.rs

1use std::fmt::Display;
2
3use bumpalo::Bump;
4use serde::ser::{Serialize, SerializeMap, Serializer};
5
6use crate::{Block, BlockMetadata, InlineNode, Location, model::inlines::converter};
7
8use super::title::Title;
9
10/// A `SectionLevel` represents a section depth in a document.
11pub type SectionLevel = u8;
12
13/// A `Section` represents a section in a document.
14#[derive(Clone, Debug, PartialEq)]
15#[non_exhaustive]
16pub struct Section<'a> {
17    pub metadata: BlockMetadata<'a>,
18    pub title: Title<'a>,
19    pub level: SectionLevel,
20    pub content: Vec<Block<'a>>,
21    pub location: Location,
22}
23
24impl<'a> Section<'a> {
25    /// Create a new section with the given title, level, content, and location.
26    #[must_use]
27    pub fn new(
28        title: Title<'a>,
29        level: SectionLevel,
30        content: Vec<Block<'a>>,
31        location: Location,
32    ) -> Self {
33        Self {
34            metadata: BlockMetadata::default(),
35            title,
36            level,
37            content,
38            location,
39        }
40    }
41
42    /// Set the metadata.
43    #[must_use]
44    pub fn with_metadata(mut self, metadata: BlockMetadata<'a>) -> Self {
45        self.metadata = metadata;
46        self
47    }
48}
49
50/// A `SafeId` represents a sanitised ID.
51#[derive(Clone, Debug, PartialEq)]
52#[non_exhaustive]
53pub enum SafeId<'a> {
54    Generated(&'a str),
55    Explicit(&'a str),
56}
57
58impl Display for SafeId<'_> {
59    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
60        match self {
61            SafeId::Generated(id) => write!(f, "_{id}"),
62            SafeId::Explicit(id) => write!(f, "{id}"),
63        }
64    }
65}
66
67impl<'a> SafeId<'a> {
68    /// Return the display-equivalent `&'a str` for this safe id without going
69    /// through `format!`/`to_string()`. `Generated` variants are prepended
70    /// with `_` into the arena; `Explicit` is returned unchanged.
71    #[must_use]
72    pub(crate) fn as_arena_str(&self, arena: &'a Bump) -> &'a str {
73        match self {
74            SafeId::Generated(id) => {
75                let mut s = bumpalo::collections::String::new_in(arena);
76                s.push('_');
77                s.push_str(id);
78                s.into_bump_str()
79            }
80            SafeId::Explicit(id) => id,
81        }
82    }
83}
84
85impl<'a> Section<'a> {
86    /// Build a section id from title text: lowercase, non-alphanumerics
87    /// (except whitespace, `-`, `.`, `_`) dropped, survivors joined with `_`,
88    /// consecutive `_` collapsed, trailing `_` trimmed. Single pass.
89    fn id_from_title(title: &[InlineNode<'a>]) -> String {
90        let mut title_text = String::new();
91        // `write_inlines` on a `String` is infallible.
92        let _ = converter::write_inlines(&mut title_text, title);
93        let mut out = String::with_capacity(title_text.len());
94        let mut last_was_underscore = false;
95        for c in title_text.to_lowercase().chars() {
96            let mapped = if c.is_alphanumeric() {
97                Some(c)
98            } else if c.is_whitespace() || c == '-' || c == '.' || c == '_' {
99                Some('_')
100            } else {
101                None
102            };
103            let Some(ch) = mapped else { continue };
104            if ch == '_' {
105                if !last_was_underscore {
106                    out.push('_');
107                }
108                last_was_underscore = true;
109            } else {
110                out.push(ch);
111                last_was_underscore = false;
112            }
113        }
114        while out.ends_with('_') {
115            out.pop();
116        }
117        out
118    }
119
120    /// Pick the explicit id if metadata provides one, else None. Shared by
121    /// the arena-returning and `String`-returning variants below.
122    fn explicit_id(metadata: &BlockMetadata<'a>) -> Option<&'a str> {
123        if let Some(anchor) = &metadata.id {
124            return Some(anchor.id);
125        }
126        metadata.anchors.last().map(|a| a.id)
127    }
128
129    /// Generate a section ID based on its title and metadata.
130    ///
131    /// Checks in order: explicit `metadata.id` (e.g. `[id=foo]`), then the last entry in
132    /// `metadata.anchors` (e.g. `[[foo]]`), otherwise auto-generates one from the title
133    /// and interns it into the supplied arena.
134    #[must_use]
135    pub(crate) fn generate_id(
136        arena: &'a Bump,
137        metadata: &BlockMetadata<'a>,
138        title: &[InlineNode<'a>],
139    ) -> SafeId<'a> {
140        match Self::explicit_id(metadata) {
141            Some(id) => SafeId::Explicit(id),
142            None => SafeId::Generated(arena.alloc_str(&Self::id_from_title(title))),
143        }
144    }
145
146    /// Generate a section ID based on its title and metadata, returning a `String`
147    /// directly.
148    ///
149    /// Returns the `Display`-formatted form (prefixed with `_` for generated IDs)
150    /// matching `safe_id.to_string()`.
151    #[must_use]
152    pub fn generate_id_string(metadata: &BlockMetadata<'a>, title: &[InlineNode<'a>]) -> String {
153        match Self::explicit_id(metadata) {
154            Some(id) => id.to_string(),
155            None => format!("_{}", Self::id_from_title(title)),
156        }
157    }
158}
159
160impl Serialize for Section<'_> {
161    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
162    where
163        S: Serializer,
164    {
165        let mut state = serializer.serialize_map(None)?;
166        state.serialize_entry("name", "section")?;
167        state.serialize_entry("type", "block")?;
168        state.serialize_entry("title", &self.title)?;
169        state.serialize_entry("level", &self.level)?;
170        if !self.metadata.is_default() {
171            state.serialize_entry("metadata", &self.metadata)?;
172        }
173        if !self.content.is_empty() {
174            state.serialize_entry("blocks", &self.content)?;
175        }
176        state.serialize_entry("location", &self.location)?;
177        state.end()
178    }
179}
180
181#[cfg(test)]
182mod tests {
183    use crate::{Anchor, Plain};
184
185    use super::*;
186
187    #[test]
188    fn test_id_from_title() {
189        let inlines: &[InlineNode] = &[InlineNode::PlainText(Plain {
190            content: "This is a title.",
191            location: Location::default(),
192            escaped: false,
193        })];
194        assert_eq!(
195            Section::id_from_title(inlines),
196            "this_is_a_title".to_string()
197        );
198        let inlines: &[InlineNode] = &[InlineNode::PlainText(Plain {
199            content: "This is a----title.",
200            location: Location::default(),
201            escaped: false,
202        })];
203        assert_eq!(
204            Section::id_from_title(inlines),
205            "this_is_a_title".to_string()
206        );
207    }
208
209    #[test]
210    fn test_id_from_title_preserves_underscores() {
211        let inlines: &[InlineNode] = &[InlineNode::PlainText(Plain {
212            content: "CHART_BOT",
213            location: Location::default(),
214            escaped: false,
215        })];
216        assert_eq!(Section::id_from_title(inlines), "chart_bot".to_string());
217        let inlines: &[InlineNode] = &[InlineNode::PlainText(Plain {
218            content: "haiku_robot",
219            location: Location::default(),
220            escaped: false,
221        })];
222        assert_eq!(Section::id_from_title(inlines), "haiku_robot".to_string());
223        let inlines: &[InlineNode] = &[InlineNode::PlainText(Plain {
224            content: "meme_transcriber",
225            location: Location::default(),
226            escaped: false,
227        })];
228        assert_eq!(
229            Section::id_from_title(inlines),
230            "meme_transcriber".to_string()
231        );
232    }
233
234    #[test]
235    fn test_section_generate_id() {
236        let arena = Bump::new();
237        let inlines: &[InlineNode] = &[InlineNode::PlainText(Plain {
238            content: "This is a b__i__g title.",
239            location: Location::default(),
240            escaped: false,
241        })];
242        // metadata has an empty id
243        let metadata = BlockMetadata::default();
244        assert_eq!(
245            Section::generate_id(&arena, &metadata, inlines),
246            SafeId::Generated("this_is_a_b_i_g_title")
247        );
248
249        // metadata has a specific id in metadata.id
250        let metadata = BlockMetadata {
251            id: Some(Anchor {
252                id: "custom_id",
253                xreflabel: None,
254                location: Location::default(),
255            }),
256            ..Default::default()
257        };
258        assert_eq!(
259            Section::generate_id(&arena, &metadata, inlines),
260            SafeId::Explicit("custom_id")
261        );
262
263        // metadata has anchor in metadata.anchors (from [[id]] or [#id] syntax)
264        let metadata = BlockMetadata {
265            anchors: vec![Anchor {
266                id: "anchor_id",
267                xreflabel: None,
268                location: Location::default(),
269            }],
270            ..Default::default()
271        };
272        assert_eq!(
273            Section::generate_id(&arena, &metadata, inlines),
274            SafeId::Explicit("anchor_id")
275        );
276
277        // with multiple anchors, the last one is used (matches asciidoctor behavior)
278        let metadata = BlockMetadata {
279            anchors: vec![
280                Anchor {
281                    id: "first_anchor",
282                    xreflabel: None,
283                    location: Location::default(),
284                },
285                Anchor {
286                    id: "last_anchor",
287                    xreflabel: None,
288                    location: Location::default(),
289                },
290            ],
291            ..Default::default()
292        };
293        assert_eq!(
294            Section::generate_id(&arena, &metadata, inlines),
295            SafeId::Explicit("last_anchor")
296        );
297
298        // metadata.id takes precedence over metadata.anchors
299        let metadata = BlockMetadata {
300            id: Some(Anchor {
301                id: "from_id",
302                xreflabel: None,
303                location: Location::default(),
304            }),
305            anchors: vec![Anchor {
306                id: "from_anchors",
307                xreflabel: None,
308                location: Location::default(),
309            }],
310            ..Default::default()
311        };
312        assert_eq!(
313            Section::generate_id(&arena, &metadata, inlines),
314            SafeId::Explicit("from_id")
315        );
316    }
317}