use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd};
use std::ops::Range;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BlockKind {
Paragraph,
Heading,
CodeBlock,
BlockQuote,
List,
Table,
HtmlBlock,
Rule,
HtmlLeaf,
FootnoteDefinition,
}
fn tag_kind(tag: &Tag<'_>) -> Option<BlockKind> {
Some(match tag {
Tag::Paragraph => BlockKind::Paragraph,
Tag::Heading { .. } => BlockKind::Heading,
Tag::CodeBlock(_) => BlockKind::CodeBlock,
Tag::BlockQuote(_) => BlockKind::BlockQuote,
Tag::List(_) => BlockKind::List,
Tag::Table(_) => BlockKind::Table,
Tag::HtmlBlock => BlockKind::HtmlBlock,
Tag::FootnoteDefinition(_) => BlockKind::FootnoteDefinition,
_ => return None,
})
}
fn tag_end_kind(tag_end: &TagEnd) -> Option<BlockKind> {
Some(match tag_end {
TagEnd::Paragraph => BlockKind::Paragraph,
TagEnd::Heading(_) => BlockKind::Heading,
TagEnd::CodeBlock => BlockKind::CodeBlock,
TagEnd::BlockQuote(_) => BlockKind::BlockQuote,
TagEnd::List(_) => BlockKind::List,
TagEnd::Table => BlockKind::Table,
TagEnd::HtmlBlock => BlockKind::HtmlBlock,
TagEnd::FootnoteDefinition => BlockKind::FootnoteDefinition,
_ => return None,
})
}
pub(crate) const PARSE_OPTIONS: Options = Options::ENABLE_TABLES
.union(Options::ENABLE_FOOTNOTES)
.union(Options::ENABLE_STRIKETHROUGH)
.union(Options::ENABLE_TASKLISTS)
.union(Options::ENABLE_SMART_PUNCTUATION);
pub struct RangeTracker<F> {
keep: F,
depth: usize,
block_start: usize,
open_kept: bool,
ranges: Vec<Range<usize>>,
}
impl<F: FnMut(BlockKind) -> bool> RangeTracker<F> {
pub fn new(keep: F) -> Self {
Self {
keep,
depth: 0,
block_start: 0,
open_kept: false,
ranges: Vec::new(),
}
}
pub fn observe(&mut self, source: &str, event: &Event<'_>, byte_range: &Range<usize>) {
match event {
Event::Start(tag) => {
if let Some(kind) = tag_kind(tag) {
if self.depth == 0 {
self.block_start = byte_range.start;
self.open_kept = (self.keep)(kind);
}
self.depth += 1;
}
}
Event::End(tag_end) => {
if tag_end_kind(tag_end).is_some() && self.depth > 0 {
self.depth -= 1;
if self.depth == 0 && self.open_kept {
let end = advance_past_newline(source, byte_range.end);
self.ranges.push(self.block_start..end);
self.open_kept = false;
}
}
}
Event::Rule => {
if self.depth == 0 && (self.keep)(BlockKind::Rule) {
let end = advance_past_newline(source, byte_range.end);
self.ranges.push(byte_range.start..end);
}
}
Event::Html(_) if self.depth == 0 && (self.keep)(BlockKind::HtmlLeaf) => {
let end = advance_past_newline(source, byte_range.end);
self.ranges.push(byte_range.start..end);
}
_ => {}
}
}
pub fn into_ranges(self) -> Vec<Range<usize>> {
self.ranges
}
}
pub fn block_ranges_by<F>(source: &str, keep: F) -> Vec<Range<usize>>
where
F: FnMut(BlockKind) -> bool,
{
let mut tracker = RangeTracker::new(keep);
for (event, byte_range) in Parser::new_ext(source, PARSE_OPTIONS).into_offset_iter() {
tracker.observe(source, &event, &byte_range);
}
tracker.into_ranges()
}
#[allow(dead_code)]
pub fn top_level_block_ranges(source: &str) -> Vec<Range<usize>> {
block_ranges_by(source, |kind| {
matches!(
kind,
BlockKind::Paragraph
| BlockKind::Heading
| BlockKind::CodeBlock
| BlockKind::BlockQuote
| BlockKind::List
| BlockKind::Table
| BlockKind::HtmlBlock
| BlockKind::Rule
| BlockKind::HtmlLeaf
| BlockKind::FootnoteDefinition
)
})
}
pub fn footnote_definition_ranges(source: &str) -> Vec<(String, Range<usize>)> {
let options = PARSE_OPTIONS;
let mut ranges: Vec<(String, Range<usize>)> = Vec::new();
let mut depth: usize = 0;
let mut open: Option<(String, usize)> = None;
for (event, byte_range) in Parser::new_ext(source, options).into_offset_iter() {
match &event {
Event::Start(tag) => {
if let Tag::FootnoteDefinition(label) = tag {
if depth == 0 {
open = Some((label.to_string(), byte_range.start));
}
}
if tag_kind(tag).is_some() {
depth += 1;
}
}
Event::End(tag_end) if tag_end_kind(tag_end).is_some() && depth > 0 => {
depth -= 1;
if depth == 0 {
if let Some((label, start)) = open.take() {
let end = advance_past_newline(source, byte_range.end);
ranges.push((label, start..end));
}
}
}
_ => {}
}
}
ranges
}
fn advance_past_newline(source: &str, pos: usize) -> usize {
if source.as_bytes().get(pos) == Some(&b'\n') {
pos + 1
} else {
pos
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn single_paragraph() {
let src = "Hello world\n";
let ranges = top_level_block_ranges(src);
assert_eq!(ranges.len(), 1);
assert_eq!(&src[ranges[0].clone()], "Hello world\n");
}
#[test]
fn heading_and_paragraph() {
let src = "# Heading\n\nParagraph\n";
let ranges = top_level_block_ranges(src);
assert_eq!(ranges.len(), 2, "expected 2 blocks, got: {:?}", ranges);
assert!(src[ranges[0].clone()].contains("Heading"));
assert!(src[ranges[1].clone()].contains("Paragraph"));
}
#[test]
fn code_block_and_paragraph() {
let src = "```\ncode\n```\n\nText\n";
let ranges = top_level_block_ranges(src);
assert_eq!(ranges.len(), 2);
assert!(src[ranges[0].clone()].contains("code"));
assert!(src[ranges[1].clone()].contains("Text"));
}
#[test]
fn horizontal_rule() {
let src = "---\n";
let ranges = top_level_block_ranges(src);
assert_eq!(ranges.len(), 1);
}
#[test]
fn footnote_definition_is_its_own_block() {
let src = "Intro.[^1]\n\n[^1]: The note.\n\nAfter.\n";
let ranges = top_level_block_ranges(src);
assert_eq!(ranges.len(), 3, "expected 3 blocks, got: {ranges:?}");
assert!(src[ranges[0].clone()].contains("Intro."));
assert!(src[ranges[1].clone()].contains("[^1]: The note."));
assert!(src[ranges[2].clone()].contains("After."));
}
#[test]
fn footnote_definition_range_covers_multiline_body() {
let src = "A[^1]\n\n[^1]: first line\n continuation line\n\nAfter.\n";
let defs = footnote_definition_ranges(src);
assert_eq!(defs.len(), 1);
let (label, range) = &defs[0];
assert_eq!(label, "1");
let text = &src[range.clone()];
assert!(text.contains("first line"), "got: {text:?}");
assert!(text.contains("continuation line"), "got: {text:?}");
assert!(!text.contains("After."), "should not absorb the next block");
}
}