use pulldown_cmark::{CodeBlockKind, Event, Options, Parser, Tag, TagEnd};
use regex::Regex;
use std::ops::Range;
use std::sync::OnceLock;
#[derive(Debug, Default, PartialEq, Eq)]
pub(crate) struct BlockTree {
pub headings: Vec<Heading>,
pub blocks: Vec<Block>,
pub block_ids: Vec<BlockId>,
pub comments: Vec<Range<usize>>,
pub code_spans: Vec<Range<usize>>,
}
#[derive(Debug, PartialEq, Eq)]
pub(crate) struct Heading {
pub level: u8,
pub text: String,
pub raw: String,
pub path: Vec<String>,
pub range: Range<usize>,
pub body_range: Range<usize>,
}
#[derive(Debug, PartialEq, Eq)]
pub(crate) struct Block {
pub kind: BlockKind,
pub range: Range<usize>,
pub heading: Option<usize>,
pub inside: Option<usize>,
}
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum BlockKind {
Paragraph,
Fence(Fence),
BlockQuote(BlockQuote),
List(List),
Table(Table),
HtmlBlock,
IndentedCode,
}
#[derive(Debug, PartialEq, Eq)]
pub(crate) struct Fence {
pub info: String,
pub lang: Option<String>,
pub code_range: Range<usize>,
}
#[derive(Debug, PartialEq, Eq)]
pub(crate) struct BlockQuote {
pub callout: Option<Callout>,
pub inner_text_range: Range<usize>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct Callout {
pub kind: String,
pub title: Option<String>,
pub fold: Option<char>,
}
#[derive(Debug, PartialEq, Eq)]
pub(crate) struct List {
pub ordered: bool,
pub start: Option<u64>,
pub items: Vec<ListItem>,
}
#[derive(Debug, PartialEq, Eq)]
pub(crate) struct ListItem {
pub range: Range<usize>,
pub text_range: Option<Range<usize>>,
pub children: Vec<List>,
}
#[derive(Debug, PartialEq, Eq)]
pub(crate) struct Table {
pub header: Vec<Cell>,
pub rows: Vec<Vec<Cell>>,
}
#[derive(Debug, PartialEq, Eq)]
pub(crate) struct Cell {
pub text: String,
pub range: Range<usize>,
}
#[derive(Debug, PartialEq, Eq)]
pub(crate) struct BlockId {
pub id: String,
pub attaches_to: Option<usize>,
pub range: Range<usize>,
pub own_line: bool,
}
fn callout_re() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| Regex::new(r"^\s*>\s*\[!([A-Za-z0-9_-]+)\]([+-])?\s*(.*)$").unwrap())
}
fn block_id_re() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| Regex::new(r"(?:^|\s)(\^([A-Za-z0-9-]+))\s*$").unwrap())
}
pub(crate) fn parse_blocks(body: &str) -> BlockTree {
let mut tree = Walker::default().run(body);
tree.comments = scan_comments(body, &tree.blocks);
drop_commented_headings(&mut tree, body.len());
tree.block_ids = scan_block_ids(body, &tree.blocks);
tree
}
fn drop_commented_headings(tree: &mut BlockTree, body_len: usize) {
let comments = std::mem::take(&mut tree.comments);
let hidden = |h: &Heading| {
comments
.iter()
.any(|c| c.start <= h.range.start && h.range.end <= c.end)
};
if tree.headings.iter().any(&hidden) {
let mut kept: Vec<Heading> = Vec::with_capacity(tree.headings.len());
let mut stack: Vec<(u8, usize)> = Vec::new();
for mut heading in std::mem::take(&mut tree.headings) {
if hidden(&heading) {
continue;
}
while let Some(&(level, index)) = stack.last() {
if level < heading.level {
break;
}
kept[index].body_range.end = heading.range.start;
stack.pop();
}
heading.path = stack.iter().map(|&(_, i)| kept[i].text.clone()).collect();
heading.path.push(heading.text.clone());
heading.body_range = heading.range.end..body_len;
stack.push((heading.level, kept.len()));
kept.push(heading);
}
tree.headings = kept;
for block in &mut tree.blocks {
let after = tree
.headings
.partition_point(|h| h.range.start <= block.range.start);
block.heading = (after > 0).then(|| after - 1);
}
}
tree.comments = comments;
}
#[derive(Default)]
struct ListBuilder {
ordered: bool,
start: Option<u64>,
items: Vec<ListItem>,
open_item: Option<OpenItem>,
}
struct OpenItem {
range: Range<usize>,
text_range: Option<Range<usize>>,
text_done: bool,
children: Vec<List>,
}
struct CodeBuilder {
fenced: bool,
range: Option<Range<usize>>,
}
#[derive(Default)]
struct TableBuilder {
header: Vec<Cell>,
rows: Vec<Vec<Cell>>,
row: Vec<Cell>,
in_head: bool,
}
#[derive(Default)]
struct Walker {
headings: Vec<Heading>,
heading_stack: Vec<(u8, usize)>,
blocks: Vec<Block>,
open_blocks: Vec<usize>,
lists: Vec<ListBuilder>,
table: Option<TableBuilder>,
code: Option<CodeBuilder>,
code_spans: Vec<Range<usize>>,
}
impl Walker {
fn run(mut self, body: &str) -> BlockTree {
let mut opts = Options::empty();
opts.insert(Options::ENABLE_TABLES);
for (event, range) in Parser::new_ext(body, opts).into_offset_iter() {
match event {
Event::Start(tag) => self.start(body, tag, range),
Event::End(tag) => self.end(body, tag, range),
Event::Text(_) if self.code.is_some() => self.extend_code(range),
Event::Code(_) => {
self.code_spans.push(range.clone());
self.extend_item_text(range);
}
Event::Rule => self.item_text_done(),
_ => self.extend_item_text(range),
}
}
BlockTree {
headings: self.headings,
blocks: self.blocks,
code_spans: self.code_spans,
..BlockTree::default()
}
}
fn open(&mut self, range: Range<usize>) -> usize {
let index = self.blocks.len();
self.blocks.push(Block {
kind: BlockKind::Paragraph,
range,
heading: self.heading_stack.last().map(|&(_, i)| i),
inside: self.open_blocks.last().copied(),
});
self.open_blocks.push(index);
index
}
fn close(&mut self, kind: BlockKind) {
if let Some(index) = self.open_blocks.pop() {
self.blocks[index].kind = kind;
}
}
fn start(&mut self, body: &str, tag: Tag<'_>, range: Range<usize>) {
match tag {
Tag::Paragraph => {
self.claim_item_text(&range);
self.open(range);
}
Tag::Heading { level, .. } => {
self.item_text_done();
self.push_heading(body, level as u8, range);
}
Tag::CodeBlock(kind) => {
self.item_text_done();
self.code = Some(CodeBuilder {
fenced: matches!(kind, CodeBlockKind::Fenced(_)),
range: None,
});
self.open(range);
}
Tag::BlockQuote(_) | Tag::HtmlBlock => {
self.item_text_done();
self.open(range);
}
Tag::Table(_) => {
self.item_text_done();
self.table = Some(TableBuilder::default());
self.open(range);
}
Tag::List(first) => {
self.item_text_done();
if self.lists.is_empty() {
self.open(range);
}
self.lists.push(ListBuilder {
ordered: first.is_some(),
start: first,
..ListBuilder::default()
});
}
Tag::Item => {
if let Some(list) = self.lists.last_mut() {
list.open_item = Some(OpenItem {
range,
text_range: None,
text_done: false,
children: Vec::new(),
});
}
}
Tag::TableHead => {
if let Some(t) = self.table.as_mut() {
t.in_head = true;
}
}
Tag::TableRow => {
if let Some(t) = self.table.as_mut() {
t.row = Vec::new();
}
}
Tag::TableCell => {
let cell = trimmed_cell(body, range);
if let Some(t) = self.table.as_mut() {
t.row.push(cell);
}
}
_ => self.extend_item_text(range),
}
}
fn end(&mut self, body: &str, tag: TagEnd, range: Range<usize>) {
match tag {
TagEnd::Paragraph => self.close(BlockKind::Paragraph),
TagEnd::HtmlBlock => self.close(BlockKind::HtmlBlock),
TagEnd::CodeBlock => self.close_code_block(body, &range),
TagEnd::BlockQuote(_) => {
let kind = block_quote(body, &range);
self.close(kind);
}
TagEnd::Table => self.close_table(),
TagEnd::List(_) => self.close_list(),
TagEnd::Item => self.close_item(range),
TagEnd::TableHead => {
if let Some(t) = self.table.as_mut() {
t.header = std::mem::take(&mut t.row);
t.in_head = false;
}
}
TagEnd::TableRow => {
if let Some(t) = self.table.as_mut() {
let row = std::mem::take(&mut t.row);
if !t.in_head {
t.rows.push(row);
}
}
}
_ => {}
}
}
fn extend_code(&mut self, range: Range<usize>) {
if let Some(code) = self.code.as_mut() {
code.range = Some(match code.range.take() {
Some(r) => r.start..range.end,
None => range,
});
}
}
fn extend_item_text(&mut self, range: Range<usize>) {
let Some(item) = self.lists.last_mut().and_then(|l| l.open_item.as_mut()) else {
return;
};
if item.text_done {
return;
}
item.text_range = Some(match item.text_range.take() {
Some(r) => r.start..r.end.max(range.end),
None => range,
});
}
fn claim_item_text(&mut self, range: &Range<usize>) {
if let Some(item) = self.lists.last_mut().and_then(|l| l.open_item.as_mut()) {
if !item.text_done {
item.text_range = Some(range.clone());
item.text_done = true;
}
}
}
fn item_text_done(&mut self) {
if let Some(item) = self.lists.last_mut().and_then(|l| l.open_item.as_mut()) {
item.text_done = true;
}
}
fn push_heading(&mut self, body: &str, level: u8, range: Range<usize>) {
while let Some(&(open_level, index)) = self.heading_stack.last() {
if open_level < level {
break;
}
self.headings[index].body_range.end = range.start;
self.heading_stack.pop();
}
let (text, raw) = heading_source(&body[range.clone()]);
let mut path: Vec<String> = self
.heading_stack
.iter()
.map(|&(_, i)| self.headings[i].text.clone())
.collect();
path.push(text.clone());
self.heading_stack.push((level, self.headings.len()));
self.headings.push(Heading {
level,
text,
raw,
path,
body_range: range.end..body.len(),
range,
});
}
fn close_code_block(&mut self, body: &str, range: &Range<usize>) {
let built = self.code.take();
let code_range = built
.as_ref()
.and_then(|c| c.range.clone())
.unwrap_or(range.end..range.end);
let kind = if built.is_some_and(|c| c.fenced) {
let info = fence_info(&body[range.clone()]);
let lang = info.split_whitespace().next().map(str::to_string);
BlockKind::Fence(Fence {
info,
lang,
code_range,
})
} else {
BlockKind::IndentedCode
};
self.close(kind);
}
fn close_table(&mut self) {
let built = self.table.take().unwrap_or_default();
self.close(BlockKind::Table(Table {
header: built.header,
rows: built.rows,
}));
}
fn close_list(&mut self) {
let Some(built) = self.lists.pop() else {
return;
};
let list = List {
ordered: built.ordered,
start: built.start,
items: built.items,
};
match self.lists.last_mut().and_then(|l| l.open_item.as_mut()) {
Some(parent) => parent.children.push(list),
None => self.close(BlockKind::List(list)),
}
}
fn close_item(&mut self, range: Range<usize>) {
let Some(list) = self.lists.last_mut() else {
return;
};
let item = match list.open_item.take() {
Some(open) => ListItem {
range: open.range,
text_range: open.text_range,
children: open.children,
},
None => ListItem {
range,
text_range: None,
children: Vec::new(),
},
};
list.items.push(item);
}
}
fn heading_source(src: &str) -> (String, String) {
let line = src.trim_end();
let atx = line.trim_start();
if !atx.starts_with('#') {
let content = match line.rfind('\n') {
Some(cut) => &line[..cut],
None => line,
};
let text = content.trim().to_string();
return (text.clone(), text);
}
let hashes = atx.len() - atx.trim_start_matches('#').len();
let raw = atx[hashes..].trim().to_string();
(strip_closing_sequence(&raw), raw)
}
fn strip_closing_sequence(raw: &str) -> String {
let trimmed = raw.trim_end();
let before = trimmed.trim_end_matches('#');
if before.len() == trimmed.len() {
return trimmed.to_string();
}
if before.is_empty() || before.ends_with(char::is_whitespace) {
before.trim_end().to_string()
} else {
trimmed.to_string()
}
}
fn fence_info(src: &str) -> String {
let first = src.trim_start().lines().next().unwrap_or("");
first
.trim_start_matches(['`', '~'])
.trim()
.replace('\r', "")
}
fn block_quote(body: &str, range: &Range<usize>) -> BlockKind {
let src = &body[range.clone()];
let first_line = src.lines().next().unwrap_or("");
let Some(caps) = callout_re().captures(first_line) else {
return BlockKind::BlockQuote(BlockQuote {
callout: None,
inner_text_range: range.clone(),
});
};
let title = caps[3].trim();
let callout = Callout {
kind: caps[1].to_ascii_lowercase(),
title: (!title.is_empty()).then(|| title.to_string()),
fold: caps.get(2).and_then(|m| m.as_str().chars().next()),
};
let after_marker = match src.find('\n') {
Some(nl) => range.start + nl + 1,
None => range.end,
};
BlockKind::BlockQuote(BlockQuote {
callout: Some(callout),
inner_text_range: after_marker.min(range.end)..range.end,
})
}
fn trimmed_cell(body: &str, range: Range<usize>) -> Cell {
let raw = &body[range.clone()];
let start = range.start + (raw.len() - raw.trim_start().len());
let end = (range.end - (raw.len() - raw.trim_end().len())).max(start);
Cell {
text: body[start..end].to_string(),
range: start..end,
}
}
fn scan_comments(body: &str, blocks: &[Block]) -> Vec<Range<usize>> {
let code: Vec<&Range<usize>> = blocks
.iter()
.filter(|b| matches!(b.kind, BlockKind::Fence(_) | BlockKind::IndentedCode))
.map(|b| &b.range)
.collect();
let mut out = Vec::new();
let mut cursor = 0;
while let Some(offset) = body[cursor..].find("%%") {
let start = cursor + offset;
let Some(close) = body[start + 2..].find("%%") else {
break;
};
let end = start + 2 + close + 2;
if !code.iter().any(|r| r.contains(&start)) {
out.push(start..end);
}
cursor = end;
}
out
}
fn scan_block_ids(body: &str, blocks: &[Block]) -> Vec<BlockId> {
let mut out = Vec::new();
for (index, block) in blocks.iter().enumerate() {
match &block.kind {
BlockKind::Paragraph => {
let Some(found) = trailing_block_id(body, &block.range) else {
continue;
};
let own_line = body[block.range.clone()].trim() == &body[found.range.clone()];
let attaches_to = if own_line {
preceding_sibling(blocks, index)
} else {
Some(index)
};
out.push(BlockId {
attaches_to,
own_line,
..found
});
}
BlockKind::List(list) => collect_item_ids(body, list, index, &mut out),
_ => {}
}
}
out.sort_by_key(|b| b.range.start);
out
}
fn collect_item_ids(body: &str, list: &List, block: usize, out: &mut Vec<BlockId>) {
for item in &list.items {
if let Some(range) = &item.text_range {
if let Some(found) = trailing_block_id(body, range) {
out.push(BlockId {
attaches_to: Some(block),
own_line: false,
..found
});
}
}
for child in &item.children {
collect_item_ids(body, child, block, out);
}
}
}
fn preceding_sibling(blocks: &[Block], index: usize) -> Option<usize> {
blocks[..index]
.iter()
.enumerate()
.rev()
.find(|(_, b)| b.inside == blocks[index].inside)
.map(|(i, _)| i)
}
fn trailing_block_id(body: &str, range: &Range<usize>) -> Option<BlockId> {
let src = body[range.clone()].trim_end();
let caps = block_id_re().captures(src)?;
let token = caps.get(1)?;
Some(BlockId {
id: caps[2].to_string(),
attaches_to: None,
range: range.start + token.start()..range.start + token.end(),
own_line: false,
})
}
#[cfg(test)]
#[path = "block_tests.rs"]
mod block_tests;