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
10pub type SectionLevel = u8;
12
13#[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 #[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 #[must_use]
44 pub fn with_metadata(mut self, metadata: BlockMetadata<'a>) -> Self {
45 self.metadata = metadata;
46 self
47 }
48}
49
50#[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 #[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 fn id_from_title(title: &[InlineNode<'a>]) -> String {
90 let mut title_text = String::new();
91 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 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 #[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 #[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 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 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 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 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 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}