1use carta_ast::{Attr, Block, Inline, Target, Text};
11
12const MAX_LEVEL: usize = 6;
14
15const UNNUMBERED: &str = "unnumbered";
17
18struct Counters {
21 levels: [u32; MAX_LEVEL],
22 base: usize,
26}
27
28impl Counters {
29 fn new(base: usize) -> Self {
30 Self {
31 levels: [0; MAX_LEVEL],
32 base: base.clamp(1, MAX_LEVEL),
33 }
34 }
35
36 fn advance(&mut self, level: i32, classes: &[Text]) -> Option<Text> {
44 if classes.iter().any(|class| class == UNNUMBERED) {
45 return None;
46 }
47 let level = usize::try_from(level).unwrap_or(1).clamp(1, MAX_LEVEL);
48 if let Some(slot) = self.levels.get_mut(level - 1) {
49 *slot += 1;
50 }
51 for slot in self.levels.iter_mut().skip(level) {
52 *slot = 0;
53 }
54 let start = self.base.saturating_sub(1).min(level.saturating_sub(1));
55 let number = self
56 .levels
57 .get(start..level)
58 .unwrap_or(&[])
59 .iter()
60 .map(u32::to_string)
61 .collect::<Vec<_>>()
62 .join(".");
63 Some(number.into())
64 }
65}
66
67fn min_heading_level(blocks: &[Block]) -> usize {
71 fn walk(blocks: &[Block], min: &mut Option<usize>) {
72 for block in blocks {
73 match block {
74 Block::Header(level, _, _) => {
75 let level = usize::try_from(*level).unwrap_or(1).clamp(1, MAX_LEVEL);
76 *min = Some(min.map_or(level, |current| current.min(level)));
77 }
78 Block::Div(_, inner) => walk(inner, min),
79 _ => {}
80 }
81 }
82 }
83 let mut min = None;
84 walk(blocks, &mut min);
85 min.unwrap_or(1)
86}
87
88pub fn number_sections(blocks: &mut [Block]) {
92 let mut counters = Counters::new(min_heading_level(blocks));
93 number_in(blocks, &mut counters);
94}
95
96fn number_in(blocks: &mut [Block], counters: &mut Counters) {
97 for block in blocks {
98 match block {
99 Block::Header(level, attr, inlines) => {
100 if let Some(computed) = counters.advance(*level, &attr.classes) {
101 let number = if let Some((_, value)) =
105 attr.attributes.iter().find(|(key, _)| key == "number")
106 {
107 value.clone()
108 } else {
109 attr.attributes
110 .push((Text::from("number"), computed.clone()));
111 computed
112 };
113 let span = Inline::Span(
114 Box::new(section_number_attr("header-section-number")),
115 vec![Inline::Str(number)],
116 );
117 inlines.splice(0..0, [span, Inline::Space]);
118 }
119 }
120 Block::Div(_, inner) => number_in(inner, counters),
121 _ => {}
122 }
123 }
124}
125
126#[must_use]
133pub fn build_toc(blocks: &[Block], depth: usize, numbered: bool, anchors: bool) -> Option<Block> {
134 let mut counters = Counters::new(min_heading_level(blocks));
135 let mut entries = Vec::new();
136 collect_entries(
137 blocks,
138 depth,
139 numbered,
140 anchors,
141 &mut counters,
142 &mut entries,
143 );
144 if entries.is_empty() {
145 None
146 } else {
147 Some(Block::BulletList(nest(&entries)))
148 }
149}
150
151struct Entry {
154 level: i32,
155 content: Vec<Inline>,
156}
157
158fn collect_entries(
159 blocks: &[Block],
160 depth: usize,
161 numbered: bool,
162 anchors: bool,
163 counters: &mut Counters,
164 entries: &mut Vec<Entry>,
165) {
166 for block in blocks {
167 match block {
168 Block::Header(level, attr, inlines) => {
169 let number = counters.advance(*level, &attr.classes);
172 if (1..=depth).contains(&usize::try_from(*level).unwrap_or(0)) {
173 entries.push(Entry {
174 level: *level,
175 content: toc_entry(attr, inlines, numbered, number.as_deref(), anchors),
176 });
177 }
178 }
179 Block::Div(_, inner) => {
180 collect_entries(inner, depth, numbered, anchors, counters, entries);
181 }
182 _ => {}
183 }
184 }
185}
186
187fn nest(entries: &[Entry]) -> Vec<Vec<Block>> {
190 let mut items = Vec::new();
191 let mut rest = entries;
192 while let Some((first, tail)) = rest.split_first() {
193 let child_count = tail
194 .iter()
195 .take_while(|entry| entry.level > first.level)
196 .count();
197 let (children, after) = tail.split_at(child_count);
198 let mut blocks = vec![Block::Plain(first.content.clone())];
199 if !children.is_empty() {
200 blocks.push(Block::BulletList(nest(children)));
201 }
202 items.push(blocks);
203 rest = after;
204 }
205 items
206}
207
208fn toc_entry(
213 attr: &Attr,
214 inlines: &[Inline],
215 numbered: bool,
216 number: Option<&str>,
217 anchors: bool,
218) -> Vec<Inline> {
219 let mut content = Vec::new();
220 if let Some(number) = number.filter(|_| numbered) {
221 content.push(Inline::Span(
222 Box::new(section_number_attr("toc-section-number")),
223 vec![Inline::Str(number.into())],
224 ));
225 content.push(Inline::Space);
226 }
227 content.extend(clean_toc_inlines(inlines));
228 if attr.id.is_empty() {
229 return content;
230 }
231 let link_attr = Attr {
232 id: if anchors {
233 format!("toc-{}", attr.id).into()
234 } else {
235 Text::default()
236 },
237 classes: Vec::new(),
238 attributes: Vec::new(),
239 };
240 vec![Inline::Link(
241 Box::new(link_attr),
242 content,
243 Box::new(Target {
244 url: format!("#{}", attr.id).into(),
245 title: Text::default(),
246 }),
247 )]
248}
249
250fn clean_toc_inlines(inlines: &[Inline]) -> Vec<Inline> {
253 let mut out = Vec::new();
254 for inline in inlines {
255 match inline {
256 Inline::Note(_) => {}
257 Inline::Link(_, inner, _) => out.extend(clean_toc_inlines(inner)),
258 Inline::Emph(inner) => out.push(Inline::Emph(clean_toc_inlines(inner))),
259 Inline::Underline(inner) => out.push(Inline::Underline(clean_toc_inlines(inner))),
260 Inline::Strong(inner) => out.push(Inline::Strong(clean_toc_inlines(inner))),
261 Inline::Strikeout(inner) => out.push(Inline::Strikeout(clean_toc_inlines(inner))),
262 Inline::Superscript(inner) => out.push(Inline::Superscript(clean_toc_inlines(inner))),
263 Inline::Subscript(inner) => out.push(Inline::Subscript(clean_toc_inlines(inner))),
264 Inline::SmallCaps(inner) => out.push(Inline::SmallCaps(clean_toc_inlines(inner))),
265 Inline::Quoted(quote, inner) => {
266 out.push(Inline::Quoted(quote.clone(), clean_toc_inlines(inner)));
267 }
268 Inline::Cite(citations, inner) => {
269 out.push(Inline::Cite(citations.clone(), clean_toc_inlines(inner)));
270 }
271 Inline::Span(attr, inner) => {
272 out.push(Inline::Span(attr.clone(), clean_toc_inlines(inner)));
273 }
274 other => out.push(other.clone()),
275 }
276 }
277 out
278}
279
280fn section_number_attr(class: &str) -> Attr {
281 Attr {
282 id: Text::default(),
283 classes: vec![class.into()],
284 attributes: Vec::new(),
285 }
286}
287
288#[cfg(test)]
289mod tests {
290 use super::*;
291 use carta_ast::Inline;
292
293 fn header(level: i32, classes: &[&str], text: &str) -> Block {
294 Block::Header(
295 level,
296 Box::new(Attr {
297 id: text.to_lowercase().replace(' ', "-").into(),
298 classes: classes.iter().map(|class| (*class).into()).collect(),
299 attributes: Vec::new(),
300 }),
301 vec![Inline::Str(text.into())],
302 )
303 }
304
305 fn number_of(block: &Block) -> Option<String> {
306 if let Block::Header(_, attr, _) = block {
307 attr.attributes
308 .iter()
309 .find(|(key, _)| key == "number")
310 .map(|(_, value)| value.to_string())
311 } else {
312 None
313 }
314 }
315
316 fn number_at(blocks: &[Block], index: usize) -> Option<String> {
317 blocks.get(index).and_then(number_of)
318 }
319
320 #[test]
321 fn numbers_increment_and_reset_by_level() {
322 let mut blocks = vec![
323 header(1, &[], "One"),
324 header(2, &[], "One A"),
325 header(2, &[], "One B"),
326 header(3, &[], "Deep"),
327 header(1, &[], "Two"),
328 ];
329 number_sections(&mut blocks);
330 let numbers: Vec<_> = blocks.iter().map(number_of).collect();
331 assert_eq!(
332 numbers,
333 vec![
334 Some("1".to_owned()),
335 Some("1.1".to_owned()),
336 Some("1.2".to_owned()),
337 Some("1.2.1".to_owned()),
338 Some("2".to_owned()),
339 ]
340 );
341 }
342
343 #[test]
344 fn document_opening_at_level_two_numbers_from_one() {
345 let mut blocks = vec![header(2, &[], "Start"), header(3, &[], "Child")];
346 number_sections(&mut blocks);
347 assert_eq!(number_at(&blocks, 0), Some("1".to_owned()));
348 assert_eq!(number_at(&blocks, 1), Some("1.1".to_owned()));
349 }
350
351 #[test]
352 fn skipped_level_reads_as_zero() {
353 let mut blocks = vec![header(1, &[], "One"), header(3, &[], "Jump")];
354 number_sections(&mut blocks);
355 assert_eq!(number_at(&blocks, 1), Some("1.0.1".to_owned()));
356 }
357
358 #[test]
359 fn deep_heading_before_its_base_level_reads_with_zero_ancestors() {
360 let mut blocks = vec![
361 header(2, &[], "Deep"),
362 header(3, &[], "Deeper"),
363 header(1, &[], "Top"),
364 ];
365 number_sections(&mut blocks);
366 assert_eq!(number_at(&blocks, 0), Some("0.1".to_owned()));
367 assert_eq!(number_at(&blocks, 1), Some("0.1.1".to_owned()));
368 assert_eq!(number_at(&blocks, 2), Some("1".to_owned()));
369 }
370
371 #[test]
372 fn shallowest_level_anchors_numbering_even_when_levels_only_deepen() {
373 let mut blocks = vec![header(6, &[], "Six"), header(5, &[], "Five")];
374 number_sections(&mut blocks);
375 assert_eq!(number_at(&blocks, 0), Some("0.1".to_owned()));
376 assert_eq!(number_at(&blocks, 1), Some("1".to_owned()));
377 }
378
379 #[test]
380 fn unnumbered_heading_keeps_counter() {
381 let mut blocks = vec![
382 header(1, &[], "One"),
383 header(2, &["unnumbered"], "Hidden"),
384 header(2, &[], "Listed"),
385 ];
386 number_sections(&mut blocks);
387 assert_eq!(number_at(&blocks, 1), None);
388 assert_eq!(number_at(&blocks, 2), Some("1.1".to_owned()));
389 }
390
391 #[test]
392 fn numbered_heading_leads_with_a_span() {
393 let mut blocks = vec![header(1, &[], "One")];
394 number_sections(&mut blocks);
395 let Some(Block::Header(_, _, inlines)) = blocks.first() else {
396 panic!("expected a header");
397 };
398 assert!(matches!(
399 inlines.first(),
400 Some(Inline::Span(attr, _)) if attr.classes == ["header-section-number"]
401 ));
402 assert!(matches!(inlines.get(1), Some(Inline::Space)));
403 }
404
405 #[test]
406 fn explicit_number_is_kept_and_still_advances_the_counter() {
407 let explicit = Block::Header(
408 2,
409 Box::new(Attr {
410 id: "c".into(),
411 classes: Vec::new(),
412 attributes: vec![("number".into(), "7.3".into())],
413 }),
414 vec![Inline::Str("C".into())],
415 );
416 let mut blocks = vec![
417 header(1, &[], "A"),
418 header(2, &[], "B"),
419 explicit,
420 header(2, &[], "D"),
421 ];
422 number_sections(&mut blocks);
423 assert_eq!(number_at(&blocks, 2), Some("7.3".to_owned()));
426 assert_eq!(number_at(&blocks, 3), Some("1.3".to_owned()));
427 let Some(Block::Header(_, attr, inlines)) = blocks.get(2) else {
428 panic!("expected a header");
429 };
430 assert_eq!(
431 attr.attributes
432 .iter()
433 .filter(|(key, _)| key == "number")
434 .count(),
435 1
436 );
437 assert!(matches!(
438 inlines.first(),
439 Some(Inline::Span(_, span)) if span == &vec![Inline::Str("7.3".into())]
440 ));
441 }
442
443 #[test]
444 fn toc_omits_headings_beyond_depth() {
445 let blocks = vec![
446 header(1, &[], "One"),
447 header(2, &[], "Two"),
448 header(3, &[], "Three"),
449 ];
450 let Some(Block::BulletList(items)) = build_toc(&blocks, 2, false, true) else {
451 panic!("expected a contents list");
452 };
453 assert_eq!(items.len(), 1);
455 let Some(Block::BulletList(children)) = items.first().and_then(|item| item.get(1)) else {
456 panic!("expected a nested list");
457 };
458 assert_eq!(children.len(), 1);
459 let Some(child) = children.first() else {
460 panic!("expected a child item");
461 };
462 assert!(child.get(1).is_none());
463 }
464
465 #[test]
466 fn empty_document_has_no_toc() {
467 assert!(
468 build_toc(
469 &[Block::Para(vec![Inline::Str("hi".into())])],
470 3,
471 false,
472 true
473 )
474 .is_none()
475 );
476 }
477
478 #[test]
479 fn toc_entry_links_to_heading() {
480 let blocks = vec![header(1, &[], "One")];
481 let Some(Block::BulletList(items)) = build_toc(&blocks, 3, true, true) else {
482 panic!("expected a contents list");
483 };
484 let Some(Block::Plain(inlines)) = items.first().and_then(|item| item.first()) else {
485 panic!("expected a plain item");
486 };
487 let Some(Inline::Link(attr, content, target)) = inlines.first() else {
488 panic!("expected a link");
489 };
490 assert_eq!(attr.id, "toc-one");
491 assert_eq!(target.url, "#one");
492 assert!(matches!(
493 content.first(),
494 Some(Inline::Span(span_attr, _)) if span_attr.classes == ["toc-section-number"]
495 ));
496 }
497
498 #[test]
499 fn toc_drops_notes_and_unwraps_links() {
500 let heading = Block::Header(
501 1,
502 Box::new(Attr {
503 id: "h".into(),
504 ..Attr::default()
505 }),
506 vec![
507 Inline::Link(
508 Box::default(),
509 vec![Inline::Str("text".into())],
510 Box::new(Target {
511 url: "x".into(),
512 title: Text::default(),
513 }),
514 ),
515 Inline::Note(vec![Block::Para(vec![Inline::Str("note".into())])]),
516 ],
517 );
518 let Some(Block::BulletList(items)) = build_toc(&[heading], 3, false, true) else {
519 panic!("expected a contents list");
520 };
521 let Some(Block::Plain(inlines)) = items.first().and_then(|item| item.first()) else {
522 panic!("expected a plain item");
523 };
524 let Some(Inline::Link(_, content, _)) = inlines.first() else {
525 panic!("expected a link");
526 };
527 assert_eq!(content, &vec![Inline::Str("text".into())]);
528 }
529
530 #[test]
531 fn toc_without_anchors_omits_entry_ids() {
532 let blocks = vec![header(1, &[], "One")];
533 let Some(Block::BulletList(items)) = build_toc(&blocks, 3, false, false) else {
534 panic!("expected a contents list");
535 };
536 let Some(Block::Plain(inlines)) = items.first().and_then(|item| item.first()) else {
537 panic!("expected a plain item");
538 };
539 let Some(Inline::Link(attr, _, target)) = inlines.first() else {
540 panic!("expected a link");
541 };
542 assert!(attr.id.is_empty());
543 assert_eq!(target.url, "#one");
544 }
545
546 #[test]
547 fn toc_entry_without_an_id_is_plain_text() {
548 let heading = Block::Header(1, Box::default(), vec![Inline::Str("Untitled".into())]);
549 let Some(Block::BulletList(items)) = build_toc(&[heading], 3, false, true) else {
550 panic!("expected a contents list");
551 };
552 let Some(Block::Plain(inlines)) = items.first().and_then(|item| item.first()) else {
553 panic!("expected a plain item");
554 };
555 assert_eq!(inlines, &vec![Inline::Str("Untitled".into())]);
556 }
557}