Skip to main content

carta_core/
sections.rs

1//! Section numbering and table-of-contents construction over the document model.
2//!
3//! [`number_sections`] splices a section number into each numbered heading: a leading
4//! `header-section-number` span carrying the number, a following space, and a `number` key/value
5//! attribute. [`build_toc`] produces a nested bullet list linking to the document's headings, which a
6//! writer renders into the `toc` template variable. Both number headings the same way — a counter per
7//! level, the result joined from the document's shallowest heading level — so a heading and its
8//! contents entry always carry the same number.
9
10use carta_ast::{Attr, Block, Inline, Target, Text};
11
12/// Headings range over levels 1 through 6.
13const MAX_LEVEL: usize = 6;
14
15/// A heading carrying this class keeps the counters unchanged and receives no number.
16const UNNUMBERED: &str = "unnumbered";
17
18/// Per-level section counters, advanced one heading at a time in document order. Numbers are joined
19/// from `base`, the document's shallowest heading level.
20struct Counters {
21    levels: [u32; MAX_LEVEL],
22    /// The shallowest heading level in the document (1-indexed), the level a number's first segment
23    /// counts. Levels between this and a deeper heading that precedes its first appearance read as
24    /// zero.
25    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    /// Advance the counters for a heading of `level` (clamped to 1..=6) and return its dotted
37    /// number, or `None` when the heading is unnumbered (the counters are then left unchanged). A
38    /// level's counter increments and every deeper level resets; the number joins the counters from
39    /// the document's shallowest heading level up to this one. A document whose shallowest heading is
40    /// level 2 still numbers from `1`; a skipped level reads as a zero (`1` then a level-3 heading is
41    /// `1.0.1`); and a heading deeper than the base level appearing before any heading reaches that
42    /// base reads with leading zeros (a level-2 heading before the first level-1 heading is `0.1`).
43    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
67/// The shallowest heading level anywhere in `blocks` (recursing through divisions), or 1 when the
68/// document has no headings. This anchors section numbering: it is the level a number's first segment
69/// counts from.
70fn 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
88/// Splice section numbers into the headings of `blocks`, walking nested sections in document order. A
89/// numbered heading gains a leading `header-section-number` span holding its number, a following
90/// space, and a `number` key/value attribute; an unnumbered heading is left untouched.
91pub 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                    // A heading may carry its own `number` — an authored override. It keeps its slot
102                    // in the sequence (the counters still advance), but the authored value is shown
103                    // and no second `number` is added.
104                    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/// Build a nested bullet list linking to the document's headings down to `depth`, or `None` when no
127/// heading qualifies. With `numbered`, each entry carries a leading `toc-section-number` span holding
128/// the heading's number (computed exactly as [`number_sections`] does); without it, entries carry
129/// only the heading text. With `anchors`, each entry's link carries its own `toc-`-prefixed id so it
130/// can be linked back to; formats that cannot represent an inline identifier pass `false` to omit it.
131/// Footnotes are dropped and links unwrapped, so an entry never nests an anchor or a note marker.
132#[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
151/// One contents entry: the heading's level (for nesting) and the prepared inlines (a link to the
152/// heading, or plain text when the heading carries no id to target).
153struct 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                // Every heading advances the counters so numbers stay consistent with the body, even
170                // those deeper than `depth` that the contents list omits.
171                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
187/// Turn a flat, document-order list of entries into a nested bullet list: each entry's deeper-level
188/// followers become its sub-list.
189fn 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
208/// The inlines for one contents entry. A numbered entry leads with a `toc-section-number` span and a
209/// space. When the heading carries an id, the entry is a link targeting it — and, with `anchors`, the
210/// link also carries its own id (the heading id prefixed with `toc-`) so it can be linked back to.
211/// When the heading has no id there is nothing to target, so the entry is plain text.
212fn 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
250/// Strip a heading's inlines for use in a contents entry: drop footnotes and replace each link with
251/// its own content (an anchor cannot nest inside the entry's anchor), recursing through styled spans.
252fn 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        // The authored number shows verbatim without a duplicate attribute, and the sibling after it
424        // keeps counting as though it had advanced normally.
425        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        // One top-level item ("One") with a single nested item ("Two"); "Three" is past depth 2.
454        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}