use carta_ast::{Attr, Block, Inline, Target, Text};
const MAX_LEVEL: usize = 6;
const UNNUMBERED: &str = "unnumbered";
struct Counters {
levels: [u32; MAX_LEVEL],
base: usize,
}
impl Counters {
fn new(base: usize) -> Self {
Self {
levels: [0; MAX_LEVEL],
base: base.clamp(1, MAX_LEVEL),
}
}
fn advance(&mut self, level: i32, classes: &[Text]) -> Option<Text> {
if classes.iter().any(|class| class == UNNUMBERED) {
return None;
}
let level = usize::try_from(level).unwrap_or(1).clamp(1, MAX_LEVEL);
if let Some(slot) = self.levels.get_mut(level - 1) {
*slot += 1;
}
for slot in self.levels.iter_mut().skip(level) {
*slot = 0;
}
let start = self.base.saturating_sub(1).min(level.saturating_sub(1));
let number = self
.levels
.get(start..level)
.unwrap_or(&[])
.iter()
.map(u32::to_string)
.collect::<Vec<_>>()
.join(".");
Some(number.into())
}
}
fn min_heading_level(blocks: &[Block]) -> usize {
fn walk(blocks: &[Block], min: &mut Option<usize>) {
for block in blocks {
match block {
Block::Header(level, _, _) => {
let level = usize::try_from(*level).unwrap_or(1).clamp(1, MAX_LEVEL);
*min = Some(min.map_or(level, |current| current.min(level)));
}
Block::Div(_, inner) => walk(inner, min),
_ => {}
}
}
}
let mut min = None;
walk(blocks, &mut min);
min.unwrap_or(1)
}
pub fn number_sections(blocks: &mut [Block]) {
let mut counters = Counters::new(min_heading_level(blocks));
number_in(blocks, &mut counters);
}
fn number_in(blocks: &mut [Block], counters: &mut Counters) {
for block in blocks {
match block {
Block::Header(level, attr, inlines) => {
if let Some(computed) = counters.advance(*level, &attr.classes) {
let number = if let Some((_, value)) =
attr.attributes.iter().find(|(key, _)| key == "number")
{
value.clone()
} else {
attr.attributes
.push((Text::from("number"), computed.clone()));
computed
};
let span = Inline::Span(
Box::new(section_number_attr("header-section-number")),
vec![Inline::Str(number)],
);
inlines.splice(0..0, [span, Inline::Space]);
}
}
Block::Div(_, inner) => number_in(inner, counters),
_ => {}
}
}
}
#[must_use]
pub fn build_toc(blocks: &[Block], depth: usize, numbered: bool, anchors: bool) -> Option<Block> {
let mut counters = Counters::new(min_heading_level(blocks));
let mut entries = Vec::new();
collect_entries(
blocks,
depth,
numbered,
anchors,
&mut counters,
&mut entries,
);
if entries.is_empty() {
None
} else {
Some(Block::BulletList(nest(&entries)))
}
}
struct Entry {
level: i32,
content: Vec<Inline>,
}
fn collect_entries(
blocks: &[Block],
depth: usize,
numbered: bool,
anchors: bool,
counters: &mut Counters,
entries: &mut Vec<Entry>,
) {
for block in blocks {
match block {
Block::Header(level, attr, inlines) => {
let number = counters.advance(*level, &attr.classes);
if (1..=depth).contains(&usize::try_from(*level).unwrap_or(0)) {
entries.push(Entry {
level: *level,
content: toc_entry(attr, inlines, numbered, number.as_deref(), anchors),
});
}
}
Block::Div(_, inner) => {
collect_entries(inner, depth, numbered, anchors, counters, entries);
}
_ => {}
}
}
}
fn nest(entries: &[Entry]) -> Vec<Vec<Block>> {
let mut items = Vec::new();
let mut rest = entries;
while let Some((first, tail)) = rest.split_first() {
let child_count = tail
.iter()
.take_while(|entry| entry.level > first.level)
.count();
let (children, after) = tail.split_at(child_count);
let mut blocks = vec![Block::Plain(first.content.clone())];
if !children.is_empty() {
blocks.push(Block::BulletList(nest(children)));
}
items.push(blocks);
rest = after;
}
items
}
fn toc_entry(
attr: &Attr,
inlines: &[Inline],
numbered: bool,
number: Option<&str>,
anchors: bool,
) -> Vec<Inline> {
let mut content = Vec::new();
if let Some(number) = number.filter(|_| numbered) {
content.push(Inline::Span(
Box::new(section_number_attr("toc-section-number")),
vec![Inline::Str(number.into())],
));
content.push(Inline::Space);
}
content.extend(clean_toc_inlines(inlines));
if attr.id.is_empty() {
return content;
}
let link_attr = Attr {
id: if anchors {
format!("toc-{}", attr.id).into()
} else {
Text::default()
},
classes: Vec::new(),
attributes: Vec::new(),
};
vec![Inline::Link(
Box::new(link_attr),
content,
Box::new(Target {
url: format!("#{}", attr.id).into(),
title: Text::default(),
}),
)]
}
fn clean_toc_inlines(inlines: &[Inline]) -> Vec<Inline> {
let mut out = Vec::new();
for inline in inlines {
match inline {
Inline::Note(_) => {}
Inline::Link(_, inner, _) => out.extend(clean_toc_inlines(inner)),
Inline::Emph(inner) => out.push(Inline::Emph(clean_toc_inlines(inner))),
Inline::Underline(inner) => out.push(Inline::Underline(clean_toc_inlines(inner))),
Inline::Strong(inner) => out.push(Inline::Strong(clean_toc_inlines(inner))),
Inline::Strikeout(inner) => out.push(Inline::Strikeout(clean_toc_inlines(inner))),
Inline::Superscript(inner) => out.push(Inline::Superscript(clean_toc_inlines(inner))),
Inline::Subscript(inner) => out.push(Inline::Subscript(clean_toc_inlines(inner))),
Inline::SmallCaps(inner) => out.push(Inline::SmallCaps(clean_toc_inlines(inner))),
Inline::Quoted(quote, inner) => {
out.push(Inline::Quoted(quote.clone(), clean_toc_inlines(inner)));
}
Inline::Cite(citations, inner) => {
out.push(Inline::Cite(citations.clone(), clean_toc_inlines(inner)));
}
Inline::Span(attr, inner) => {
out.push(Inline::Span(attr.clone(), clean_toc_inlines(inner)));
}
other => out.push(other.clone()),
}
}
out
}
fn section_number_attr(class: &str) -> Attr {
Attr {
id: Text::default(),
classes: vec![class.into()],
attributes: Vec::new(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use carta_ast::Inline;
fn header(level: i32, classes: &[&str], text: &str) -> Block {
Block::Header(
level,
Box::new(Attr {
id: text.to_lowercase().replace(' ', "-").into(),
classes: classes.iter().map(|class| (*class).into()).collect(),
attributes: Vec::new(),
}),
vec![Inline::Str(text.into())],
)
}
fn number_of(block: &Block) -> Option<String> {
if let Block::Header(_, attr, _) = block {
attr.attributes
.iter()
.find(|(key, _)| key == "number")
.map(|(_, value)| value.to_string())
} else {
None
}
}
fn number_at(blocks: &[Block], index: usize) -> Option<String> {
blocks.get(index).and_then(number_of)
}
#[test]
fn numbers_increment_and_reset_by_level() {
let mut blocks = vec![
header(1, &[], "One"),
header(2, &[], "One A"),
header(2, &[], "One B"),
header(3, &[], "Deep"),
header(1, &[], "Two"),
];
number_sections(&mut blocks);
let numbers: Vec<_> = blocks.iter().map(number_of).collect();
assert_eq!(
numbers,
vec![
Some("1".to_owned()),
Some("1.1".to_owned()),
Some("1.2".to_owned()),
Some("1.2.1".to_owned()),
Some("2".to_owned()),
]
);
}
#[test]
fn document_opening_at_level_two_numbers_from_one() {
let mut blocks = vec![header(2, &[], "Start"), header(3, &[], "Child")];
number_sections(&mut blocks);
assert_eq!(number_at(&blocks, 0), Some("1".to_owned()));
assert_eq!(number_at(&blocks, 1), Some("1.1".to_owned()));
}
#[test]
fn skipped_level_reads_as_zero() {
let mut blocks = vec![header(1, &[], "One"), header(3, &[], "Jump")];
number_sections(&mut blocks);
assert_eq!(number_at(&blocks, 1), Some("1.0.1".to_owned()));
}
#[test]
fn deep_heading_before_its_base_level_reads_with_zero_ancestors() {
let mut blocks = vec![
header(2, &[], "Deep"),
header(3, &[], "Deeper"),
header(1, &[], "Top"),
];
number_sections(&mut blocks);
assert_eq!(number_at(&blocks, 0), Some("0.1".to_owned()));
assert_eq!(number_at(&blocks, 1), Some("0.1.1".to_owned()));
assert_eq!(number_at(&blocks, 2), Some("1".to_owned()));
}
#[test]
fn shallowest_level_anchors_numbering_even_when_levels_only_deepen() {
let mut blocks = vec![header(6, &[], "Six"), header(5, &[], "Five")];
number_sections(&mut blocks);
assert_eq!(number_at(&blocks, 0), Some("0.1".to_owned()));
assert_eq!(number_at(&blocks, 1), Some("1".to_owned()));
}
#[test]
fn unnumbered_heading_keeps_counter() {
let mut blocks = vec![
header(1, &[], "One"),
header(2, &["unnumbered"], "Hidden"),
header(2, &[], "Listed"),
];
number_sections(&mut blocks);
assert_eq!(number_at(&blocks, 1), None);
assert_eq!(number_at(&blocks, 2), Some("1.1".to_owned()));
}
#[test]
fn numbered_heading_leads_with_a_span() {
let mut blocks = vec![header(1, &[], "One")];
number_sections(&mut blocks);
let Some(Block::Header(_, _, inlines)) = blocks.first() else {
panic!("expected a header");
};
assert!(matches!(
inlines.first(),
Some(Inline::Span(attr, _)) if attr.classes == ["header-section-number"]
));
assert!(matches!(inlines.get(1), Some(Inline::Space)));
}
#[test]
fn explicit_number_is_kept_and_still_advances_the_counter() {
let explicit = Block::Header(
2,
Box::new(Attr {
id: "c".into(),
classes: Vec::new(),
attributes: vec![("number".into(), "7.3".into())],
}),
vec![Inline::Str("C".into())],
);
let mut blocks = vec![
header(1, &[], "A"),
header(2, &[], "B"),
explicit,
header(2, &[], "D"),
];
number_sections(&mut blocks);
assert_eq!(number_at(&blocks, 2), Some("7.3".to_owned()));
assert_eq!(number_at(&blocks, 3), Some("1.3".to_owned()));
let Some(Block::Header(_, attr, inlines)) = blocks.get(2) else {
panic!("expected a header");
};
assert_eq!(
attr.attributes
.iter()
.filter(|(key, _)| key == "number")
.count(),
1
);
assert!(matches!(
inlines.first(),
Some(Inline::Span(_, span)) if span == &vec![Inline::Str("7.3".into())]
));
}
#[test]
fn toc_omits_headings_beyond_depth() {
let blocks = vec![
header(1, &[], "One"),
header(2, &[], "Two"),
header(3, &[], "Three"),
];
let Some(Block::BulletList(items)) = build_toc(&blocks, 2, false, true) else {
panic!("expected a contents list");
};
assert_eq!(items.len(), 1);
let Some(Block::BulletList(children)) = items.first().and_then(|item| item.get(1)) else {
panic!("expected a nested list");
};
assert_eq!(children.len(), 1);
let Some(child) = children.first() else {
panic!("expected a child item");
};
assert!(child.get(1).is_none());
}
#[test]
fn empty_document_has_no_toc() {
assert!(
build_toc(
&[Block::Para(vec![Inline::Str("hi".into())])],
3,
false,
true
)
.is_none()
);
}
#[test]
fn toc_entry_links_to_heading() {
let blocks = vec![header(1, &[], "One")];
let Some(Block::BulletList(items)) = build_toc(&blocks, 3, true, true) else {
panic!("expected a contents list");
};
let Some(Block::Plain(inlines)) = items.first().and_then(|item| item.first()) else {
panic!("expected a plain item");
};
let Some(Inline::Link(attr, content, target)) = inlines.first() else {
panic!("expected a link");
};
assert_eq!(attr.id, "toc-one");
assert_eq!(target.url, "#one");
assert!(matches!(
content.first(),
Some(Inline::Span(span_attr, _)) if span_attr.classes == ["toc-section-number"]
));
}
#[test]
fn toc_drops_notes_and_unwraps_links() {
let heading = Block::Header(
1,
Box::new(Attr {
id: "h".into(),
..Attr::default()
}),
vec![
Inline::Link(
Box::default(),
vec![Inline::Str("text".into())],
Box::new(Target {
url: "x".into(),
title: Text::default(),
}),
),
Inline::Note(vec![Block::Para(vec![Inline::Str("note".into())])]),
],
);
let Some(Block::BulletList(items)) = build_toc(&[heading], 3, false, true) else {
panic!("expected a contents list");
};
let Some(Block::Plain(inlines)) = items.first().and_then(|item| item.first()) else {
panic!("expected a plain item");
};
let Some(Inline::Link(_, content, _)) = inlines.first() else {
panic!("expected a link");
};
assert_eq!(content, &vec![Inline::Str("text".into())]);
}
#[test]
fn toc_without_anchors_omits_entry_ids() {
let blocks = vec![header(1, &[], "One")];
let Some(Block::BulletList(items)) = build_toc(&blocks, 3, false, false) else {
panic!("expected a contents list");
};
let Some(Block::Plain(inlines)) = items.first().and_then(|item| item.first()) else {
panic!("expected a plain item");
};
let Some(Inline::Link(attr, _, target)) = inlines.first() else {
panic!("expected a link");
};
assert!(attr.id.is_empty());
assert_eq!(target.url, "#one");
}
#[test]
fn toc_entry_without_an_id_is_plain_text() {
let heading = Block::Header(1, Box::default(), vec![Inline::Str("Untitled".into())]);
let Some(Block::BulletList(items)) = build_toc(&[heading], 3, false, true) else {
panic!("expected a contents list");
};
let Some(Block::Plain(inlines)) = items.first().and_then(|item| item.first()) else {
panic!("expected a plain item");
};
assert_eq!(inlines, &vec![Inline::Str("Untitled".into())]);
}
}