use std::collections::HashMap;
use asciidoc_parser::{
Document,
HasSpan,
blocks::{
Block,
FindBlocks,
IsBlock,
SectionBlock,
SectionType,
},
};
const SPECIAL: &[&str] = &[
"colophon",
"dedication",
"acknowledgments",
"preface",
"partintro",
"glossary",
"bibliography",
"index",
];
const ABSTRACT: &str = "abstract";
#[derive(Debug)]
pub struct Numbering(HashMap<usize, String>);
enum From {
Sequence(String),
Nothing,
Parser,
}
impl Numbering {
pub fn of(document: &Document<'_>) -> Self {
let mut numbers = HashMap::new();
let book = matches!(
document.attribute_value("doctype"),
asciidoc_parser::document::InterpretedValue::Value(value) if value == "book"
);
assign(
document.child_blocks(),
&From::Sequence(String::new()),
book,
&mut 0,
&mut numbers,
);
Self(numbers)
}
pub fn prefix(&self, section: &SectionBlock<'_>) -> String {
self.0
.get(§ion.span().byte_offset())
.cloned()
.unwrap_or_else(|| parser_prefix(section))
}
}
fn assign<'src>(
blocks: impl Iterator<Item = &'src Block<'src>>,
from: &From,
book: bool,
counter: &mut usize,
numbers: &mut HashMap<usize, String>,
) {
let mut chapters = 0usize;
for block in blocks {
let Block::Section(section) = block else {
continue;
};
if section.section_type() == SectionType::Discrete {
numbers.insert(section.span().byte_offset(), String::new());
continue;
}
let (prefix, beneath) = shape(section, from, book, counter);
numbers.insert(section.span().byte_offset(), prefix);
let mut own = 0usize;
let beneath_counter = if is_part(section, book) {
&mut chapters
} else {
&mut own
};
assign(
section.child_blocks(),
&beneath,
book,
beneath_counter,
numbers,
);
}
}
fn is_part(section: &SectionBlock<'_>, book: bool) -> bool {
book && section.level() == 0
}
fn shape(
section: &SectionBlock<'_>,
from: &From,
book: bool,
counter: &mut usize,
) -> (String, From) {
if section.section_type() == SectionType::Appendix {
return (parser_prefix(section), From::Parser);
}
if is_special(section, book) {
return (String::new(), From::Nothing);
}
match from {
From::Nothing => (String::new(), From::Nothing),
From::Parser => (parser_prefix(section), From::Parser),
From::Sequence(prefix) => {
if section.section_number().is_none() {
return (String::new(), From::Sequence(prefix.clone()));
}
*counter += 1;
let number = format!("{prefix}{counter}.");
(format!("{number} "), From::Sequence(number))
}
}
}
fn is_special(section: &SectionBlock<'_>, book: bool) -> bool {
section
.declared_style()
.is_some_and(|style| SPECIAL.contains(&style) || (style == ABSTRACT && !book))
}
fn parser_prefix(section: &SectionBlock<'_>) -> String {
if let Some(caption) = section.caption() {
return caption.to_string();
}
match section.section_number() {
Some(number) => format!("{number}. "),
None => String::new(),
}
}
#[cfg(test)]
mod tests {
use asciidoc_parser::{
Parser,
blocks::{
Block,
FindBlocks,
},
};
use super::*;
fn headings(source: &str) -> Vec<String> {
let mut parser = Parser::default();
let document = parser.parse(source);
let numbering = Numbering::of(&document);
let mut out = Vec::new();
walk(document.child_blocks(), &numbering, &mut out);
out
}
fn walk<'src>(
blocks: impl Iterator<Item = &'src Block<'src>>,
numbering: &Numbering,
out: &mut Vec<String>,
) {
for block in blocks {
let Block::Section(section) = block else {
continue;
};
out.push(format!(
"{}{}",
numbering.prefix(section),
section.section_title()
));
walk(section.child_blocks(), numbering, out);
}
}
#[test]
fn a_special_section_is_not_numbered_and_is_not_counted() {
let numbers = headings("= D\n:sectnums:\n\n== One\n\n[glossary]\n== Glossary\n\n== Two\n");
assert_eq!(numbers, ["1. One", "Glossary", "2. Two"]);
}
#[test]
fn every_special_style_stands_outside_the_sequence() {
for style in [
"colophon",
"dedication",
"acknowledgments",
"preface",
"glossary",
"bibliography",
"index",
"abstract",
] {
let numbers = headings(&format!(
"= D\n:sectnums:\n\n[{style}]\n== Special\n\n== One\n"
));
assert_eq!(numbers, ["Special", "1. One"], "`{style}` should stand out");
}
}
#[test]
fn nothing_beneath_a_special_section_is_numbered() {
let numbers =
headings("= D\n:sectnums:\n\n[preface]\n== Preface\n\n=== Inside\n\n== One\n");
assert_eq!(numbers, ["Preface", "Inside", "1. One"]);
}
#[test]
fn a_special_section_stands_out_at_any_level() {
let numbers =
headings("= D\n:sectnums:\n\n== One\n\n=== A\n\n[glossary]\n=== Gloss\n\n=== B\n");
assert_eq!(numbers, ["1. One", "1.1. A", "Gloss", "1.2. B"]);
}
#[test]
fn an_appendix_is_lettered_and_its_sections_numbered_from_the_letter() {
let numbers =
headings("= D\n:sectnums:\n\n== One\n\n[appendix]\n== App\n\n=== Inside\n\n== Two\n");
assert_eq!(
numbers,
["1. One", "Appendix A: App", "A.1. Inside", "2. Two"]
);
}
#[test]
fn a_books_chapters_run_on_across_its_parts() {
let numbers = headings(
"= B\n:doctype: book\n:sectnums:\n\n= Part One\n\n== A\n\n== B\n\n= Part Two\n\n== C\n",
);
assert_eq!(numbers, ["Part One", "1. A", "2. B", "Part Two", "3. C"]);
}
#[test]
fn a_books_abstract_is_a_chapter_and_an_articles_is_not() {
let book =
headings("= B\n:doctype: book\n:sectnums:\n\n[abstract]\n== Abstract\n\n== One\n");
assert_eq!(book, ["1. Abstract", "2. One"]);
let article = headings("= A\n:sectnums:\n\n[abstract]\n== Abstract\n\n== One\n");
assert_eq!(article, ["Abstract", "1. One"]);
}
#[test]
fn a_document_that_numbers_nothing_shows_nothing() {
let numbers = headings("= D\n\n== One\n\n[glossary]\n== Glossary\n\n== Two\n");
assert_eq!(numbers, ["One", "Glossary", "Two"]);
}
#[test]
fn numbering_stops_where_sectnumlevels_says() {
let numbers = headings("= D\n:sectnums:\n:sectnumlevels: 1\n\n== One\n\n=== A\n\n== Two\n");
assert_eq!(numbers, ["1. One", "A", "2. Two"]);
}
}