use std::{collections::HashMap, ops::Range};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum BlockKind {
#[default]
Paragraph,
Heading,
ListItem,
Quote,
Code,
TableRow,
Figure,
Rule,
}
impl BlockKind {
#[must_use]
pub const fn code(self) -> i32 {
match self {
Self::Paragraph => 0,
Self::Heading => 1,
Self::ListItem => 2,
Self::Quote => 3,
Self::Code => 4,
Self::TableRow => 5,
Self::Figure => 6,
Self::Rule => 7,
}
}
}
#[derive(Debug, Clone, PartialEq, Default)]
pub struct Block {
pub kind: BlockKind,
pub level: u8,
pub indent: u8,
pub marker: String,
pub text: String,
pub cells: Vec<String>,
pub header: bool,
pub weights: Vec<f32>,
pub section: usize,
}
impl Block {
fn new(kind: BlockKind) -> Self {
Self {
kind,
..Self::default()
}
}
#[must_use]
pub fn plain_text(&self) -> String {
match self.kind {
BlockKind::Heading | BlockKind::Code => self.text.clone(),
BlockKind::Paragraph | BlockKind::ListItem | BlockKind::Quote | BlockKind::Figure => {
plain_text(&self.text)
}
BlockKind::TableRow => self
.cells
.iter()
.map(|cell| plain_text(cell))
.collect::<Vec<_>>()
.join(" | "),
BlockKind::Rule => String::new(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Section {
pub level: u8,
pub title: String,
pub slug: String,
pub range: Range<usize>,
}
#[derive(Debug, Clone, PartialEq, Default)]
pub struct Chapter {
pub title: String,
pub sections: Vec<Section>,
pub blocks: Vec<Block>,
}
impl Chapter {
#[must_use]
pub fn section_by_slug(&self, slug: &str) -> Option<usize> {
self.sections
.iter()
.position(|section| section.slug == slug)
}
#[must_use]
pub fn section_body(&self, index: usize) -> &[Block] {
let Some(blocks) = self
.sections
.get(index)
.and_then(|section| self.blocks.get(section.range.clone()))
else {
return &[];
};
match blocks.split_first() {
Some((first, rest)) if first.kind == BlockKind::Heading => rest,
_ => blocks,
}
}
#[must_use]
pub fn section_plain_text(&self, index: usize) -> String {
self.section_body(index)
.iter()
.map(Block::plain_text)
.filter(|text| !text.is_empty())
.collect::<Vec<_>>()
.join(" ")
}
}
#[must_use]
pub fn slug(heading: &str) -> String {
plain_text(heading)
.trim()
.chars()
.flat_map(char::to_lowercase)
.filter_map(|c| {
if c.is_alphanumeric() || c == '-' || c == '_' {
Some(c)
} else if c.is_whitespace() {
Some('-')
} else {
None
}
})
.collect()
}
#[must_use]
pub fn plain_text(markdown: &str) -> String {
let chars: Vec<char> = markdown.chars().collect();
let mut out = String::with_capacity(markdown.len());
let mut i = 0;
while i < chars.len() {
let c = chars[i];
let next = chars.get(i + 1).copied();
if c == '\\'
&& let Some(mark) = next
&& mark.is_ascii_punctuation()
{
out.push(mark);
i += 2;
} else if c == '!'
&& next == Some('[')
&& let Some((label, end)) = link_at(&chars, i + 1)
{
out.push_str(&plain_text(&label));
i = end;
} else if c == '['
&& let Some((label, end)) = link_at(&chars, i)
{
out.push_str(&plain_text(&label));
i = end;
} else if c == '`' {
if let Some(length) = chars[i + 1..].iter().position(|&other| other == '`') {
out.extend(&chars[i + 1..i + 1 + length]);
i += length + 2;
} else {
i += 1;
}
} else if c == '*' {
i += 1;
} else if c == '~' && next == Some('~') {
i += 2;
} else {
out.push(c);
i += 1;
}
}
out
}
fn link_at(chars: &[char], open: usize) -> Option<(String, usize)> {
let close = open + 1 + chars.get(open + 1..)?.iter().position(|&c| c == ']')?;
if chars.get(close + 1) != Some(&'(') {
return None;
}
let end = close + 2 + chars.get(close + 2..)?.iter().position(|&c| c == ')')?;
Some((chars[open + 1..close].iter().collect(), end + 1))
}
#[must_use]
pub fn parse_chapter(source: &str) -> Chapter {
let mut parser = Parser {
lines: source.lines().collect(),
pos: 0,
blocks: Vec::new(),
sections: Vec::new(),
items: Vec::new(),
slugs: HashMap::new(),
};
parser.run();
parser.finish()
}
struct Parser<'a> {
lines: Vec<&'a str>,
pos: usize,
blocks: Vec<Block>,
sections: Vec<Section>,
items: Vec<(usize, usize)>,
slugs: HashMap<String, usize>,
}
impl Parser<'_> {
fn run(&mut self) {
while let Some(&line) = self.lines.get(self.pos) {
if is_blank(line) {
self.pos += 1;
} else if line.trim_start().starts_with("<!--") {
self.skip_comment();
} else if let Some(fence) = fence_of(line) {
self.code_block(fence);
} else if let Some((level, text)) = heading_of(line) {
self.heading(level, text);
self.pos += 1;
} else if is_rule(line) {
let indent = self.block_indent(line);
self.push(Block {
indent,
..Block::new(BlockKind::Rule)
});
self.pos += 1;
} else if table_start(&self.lines, self.pos) {
self.table();
} else if quote_text(line).is_some() {
self.quote();
} else if let Some(item) = list_line(line) {
self.list_item(&item);
} else if let Some((alt, path)) = figure_of(line) {
let indent = self.block_indent(line);
self.push(Block {
indent,
text: alt,
marker: path,
..Block::new(BlockKind::Figure)
});
self.pos += 1;
} else {
self.paragraph();
}
}
}
fn finish(self) -> Chapter {
let Self {
blocks,
mut sections,
..
} = self;
let starts: Vec<usize> = sections.iter().map(|section| section.range.start).collect();
for (index, section) in sections.iter_mut().enumerate() {
section.range.end = starts.get(index + 1).copied().unwrap_or(blocks.len());
}
let title = sections
.iter()
.filter(|section| !section.title.is_empty())
.min_by_key(|section| section.level)
.map(|section| section.title.clone())
.unwrap_or_default();
Chapter {
title,
sections,
blocks,
}
}
fn ensure_section(&mut self) {
if self.sections.is_empty() {
let at = self.blocks.len();
self.sections.push(Section {
level: 1,
title: String::new(),
slug: String::new(),
range: at..at,
});
}
}
fn push(&mut self, mut block: Block) {
self.ensure_section();
block.section = self.sections.len().saturating_sub(1);
self.blocks.push(block);
}
fn unique_slug(&mut self, title: &str) -> String {
let base = slug(title);
let count = self.slugs.entry(base.clone()).or_insert(0);
let unique = if *count == 0 {
base
} else {
format!("{base}-{count}")
};
*count += 1;
unique
}
fn heading(&mut self, level: u8, raw: &str) {
self.items.clear();
let title = plain_text(raw);
let anchor = self.unique_slug(&title);
let at = self.blocks.len();
self.sections.push(Section {
level,
title: title.clone(),
slug: anchor,
range: at..at,
});
self.blocks.push(Block {
level,
text: title,
section: self.sections.len() - 1,
..Block::new(BlockKind::Heading)
});
}
fn block_indent(&mut self, line: &str) -> u8 {
let column = indent_of(line);
while self
.items
.last()
.is_some_and(|&(_, content)| content > column)
{
self.items.pop();
}
u8::try_from(self.items.len()).unwrap_or(u8::MAX)
}
fn skip_comment(&mut self) {
while let Some(line) = self.lines.get(self.pos) {
self.pos += 1;
if line.contains("-->") {
break;
}
}
}
fn code_block(&mut self, fence: &str) {
let open = self.lines[self.pos];
let open_column = indent_of(open);
let indent = self.block_indent(open);
self.pos += 1;
let mut code = Vec::new();
while let Some(&line) = self.lines.get(self.pos) {
self.pos += 1;
if line.trim_start().starts_with(fence) {
break;
}
code.push(strip_columns(line, open_column));
}
self.push(Block {
indent,
text: code.join("\n"),
..Block::new(BlockKind::Code)
});
}
fn paragraph(&mut self) {
let indent = self.block_indent(self.lines[self.pos]);
let mut text = String::new();
while let Some(&line) = self.lines.get(self.pos) {
if !text.is_empty() && starts_block(&self.lines, self.pos, false) {
break;
}
if !text.is_empty() {
text.push(' ');
}
text.push_str(line.trim());
self.pos += 1;
}
self.push(Block {
indent,
text,
..Block::new(BlockKind::Paragraph)
});
}
fn list_item(&mut self, item: &ListLine) {
while self
.items
.last()
.is_some_and(|&(marker, _)| marker >= item.indent)
{
self.items.pop();
}
let depth = self.items.len();
self.items.push((item.indent, item.content_indent));
let mut text = item.text.trim().to_owned();
self.pos += 1;
while self.pos < self.lines.len() && !starts_block(&self.lines, self.pos, true) {
text.push(' ');
text.push_str(self.lines[self.pos].trim());
self.pos += 1;
}
let marker = if item.ordered {
item.marker.clone()
} else if depth == 0 {
"\u{2022}".to_owned()
} else {
"\u{2013}".to_owned()
};
self.push(Block {
indent: u8::try_from(depth).unwrap_or(u8::MAX),
marker,
text,
..Block::new(BlockKind::ListItem)
});
}
fn quote(&mut self) {
let indent = self.block_indent(self.lines[self.pos]);
let mut paragraphs = vec![String::new()];
while let Some(rest) = self.lines.get(self.pos).and_then(|line| quote_text(line)) {
let rest = rest.trim();
if rest.is_empty() {
if paragraphs.last().is_some_and(|last| !last.is_empty()) {
paragraphs.push(String::new());
}
} else if let Some(last) = paragraphs.last_mut() {
if !last.is_empty() {
last.push(' ');
}
last.push_str(rest);
}
self.pos += 1;
}
for text in paragraphs.into_iter().filter(|text| !text.is_empty()) {
self.push(Block {
indent,
text,
..Block::new(BlockKind::Quote)
});
}
}
fn table(&mut self) {
let indent = self.block_indent(self.lines[self.pos]);
let mut rows = vec![split_row(self.lines[self.pos])];
self.pos += 2;
while let Some(&line) = self.lines.get(self.pos) {
if is_blank(line) || !line.contains('|') {
break;
}
rows.push(split_row(line));
self.pos += 1;
}
let columns = rows.first().map_or(0, Vec::len);
for row in &mut rows {
row.resize(columns, String::new());
}
let weights = column_weights(&rows, columns);
for (index, cells) in rows.into_iter().enumerate() {
self.push(Block {
indent,
cells,
header: index == 0,
weights: weights.clone(),
..Block::new(BlockKind::TableRow)
});
}
}
}
fn column_weights(rows: &[Vec<String>], columns: usize) -> Vec<f32> {
(0..columns)
.map(|column| {
let lengths: Vec<usize> = rows
.iter()
.map(|row| {
row.get(column)
.map_or(0, |cell| plain_text(cell).chars().count())
})
.collect();
let longest = lengths.iter().copied().max().unwrap_or(0) as f32;
let mean = lengths.iter().sum::<usize>() as f32 / lengths.len().max(1) as f32;
longest.midpoint(mean).clamp(6.0, 60.0)
})
.collect()
}
fn is_blank(line: &str) -> bool {
line.trim().is_empty()
}
fn indent_of(line: &str) -> usize {
line.chars()
.take_while(|c| c.is_whitespace())
.map(|c| if c == '\t' { 4 } else { 1 })
.sum()
}
fn strip_columns(line: &str, columns: usize) -> String {
let mut remaining = columns;
let mut start = 0;
for (at, c) in line.char_indices() {
let width = match c {
' ' => 1,
'\t' => 4,
_ => break,
};
if remaining < width {
break;
}
remaining -= width;
start = at + c.len_utf8();
}
line[start..].trim_end().to_owned()
}
fn fence_of(line: &str) -> Option<&'static str> {
let trimmed = line.trim_start();
if trimmed.starts_with("```") {
Some("```")
} else if trimmed.starts_with("~~~") {
Some("~~~")
} else {
None
}
}
fn heading_of(line: &str) -> Option<(u8, &str)> {
if indent_of(line) > 3 {
return None;
}
let trimmed = line.trim_start();
let hashes = trimmed.chars().take_while(|&c| c == '#').count();
let level = u8::try_from(hashes)
.ok()
.filter(|level| (1..=6).contains(level))?;
let rest = &trimmed[hashes..];
if !rest.is_empty() && !rest.starts_with([' ', '\t']) {
return None;
}
Some((level, rest.trim()))
}
fn is_rule(line: &str) -> bool {
if indent_of(line) > 3 {
return false;
}
let compact: Vec<char> = line.chars().filter(|c| !c.is_whitespace()).collect();
compact.len() >= 3
&& ['-', '*', '_']
.iter()
.any(|&mark| compact.iter().all(|&c| c == mark))
}
fn quote_text(line: &str) -> Option<&str> {
let rest = line.trim_start().strip_prefix('>')?;
Some(rest.strip_prefix(' ').unwrap_or(rest))
}
fn figure_of(line: &str) -> Option<(String, String)> {
let rest = line.trim().strip_prefix("?;
let path = tail.strip_suffix(')')?;
Some((plain_text(alt), path.to_owned()))
}
struct ListLine<'a> {
indent: usize,
marker: String,
ordered: bool,
number: Option<u64>,
content_indent: usize,
text: &'a str,
}
fn list_line(line: &str) -> Option<ListLine<'_>> {
let indent = indent_of(line);
let trimmed = line.trim_start();
let first = trimmed.chars().next()?;
let (marker_len, number) = if matches!(first, '-' | '*' | '+') {
(1, None)
} else if first.is_ascii_digit() {
let digits = trimmed.chars().take_while(char::is_ascii_digit).count();
let delimiter = trimmed[digits..].chars().next()?;
if digits > 9 || !matches!(delimiter, '.' | ')') {
return None;
}
(digits + 1, trimmed[..digits].parse::<u64>().ok())
} else {
return None;
};
let after = &trimmed[marker_len..];
if !after.starts_with([' ', '\t']) {
return None;
}
let text = after.trim_start();
if text.is_empty() {
return None;
}
let gap = (after.len() - text.len()).min(4);
Some(ListLine {
indent,
marker: trimmed[..marker_len].to_owned(),
ordered: number.is_some(),
number,
content_indent: indent + marker_len + gap,
text,
})
}
fn table_start(lines: &[&str], at: usize) -> bool {
lines
.get(at)
.is_some_and(|line| line.trim_start().starts_with('|'))
&& lines
.get(at + 1)
.is_some_and(|line| is_table_separator(line))
}
fn is_table_separator(line: &str) -> bool {
let trimmed = line.trim();
trimmed.contains('-')
&& trimmed.contains('|')
&& trimmed.chars().all(|c| matches!(c, '|' | '-' | ':' | ' '))
}
fn split_row(line: &str) -> Vec<String> {
let trimmed = line.trim();
let trimmed = trimmed.strip_prefix('|').unwrap_or(trimmed);
let trimmed = trimmed.strip_suffix('|').unwrap_or(trimmed);
let mut cells = Vec::new();
let mut cell = String::new();
let mut chars = trimmed.chars().peekable();
while let Some(c) = chars.next() {
if c == '\\' && chars.peek() == Some(&'|') {
cell.push_str("\\|");
chars.next();
} else if c == '|' {
cells.push(cell.trim().to_owned());
cell.clear();
} else {
cell.push(c);
}
}
cells.push(cell.trim().to_owned());
cells
}
fn starts_block(lines: &[&str], at: usize, in_item: bool) -> bool {
let Some(&line) = lines.get(at) else {
return true;
};
if is_blank(line)
|| heading_of(line).is_some()
|| fence_of(line).is_some()
|| is_rule(line)
|| quote_text(line).is_some()
|| line.trim_start().starts_with("<!--")
|| table_start(lines, at)
{
return true;
}
list_line(line).is_some_and(|item| in_item || item.number.is_none_or(|number| number == 1))
}
#[cfg(test)]
mod tests;