use std::ops::Range;
use pulldown_cmark::{Event, OffsetIter, Options, Parser, RefDefs, Tag, TagEnd};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BlockKind {
Paragraph,
Heading,
CodeBlock,
BlockQuote,
List,
Table,
HtmlBlock,
Rule,
HtmlLeaf,
FootnoteDefinition,
MetadataBlock,
}
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,
Tag::MetadataBlock(_) => BlockKind::MetadataBlock,
_ => 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,
TagEnd::MetadataBlock(_) => BlockKind::MetadataBlock,
_ => return None,
})
}
pub(crate) const BASE_OPTIONS: Options = Options::ENABLE_TABLES
.union(Options::ENABLE_FOOTNOTES)
.union(Options::ENABLE_STRIKETHROUGH)
.union(Options::ENABLE_TASKLISTS)
.union(Options::ENABLE_SMART_PUNCTUATION)
.union(Options::ENABLE_MATH);
pub struct DocParser<'a> {
head: Option<Parser<'a>>,
body: Parser<'a>,
body_start: usize,
}
impl<'a> DocParser<'a> {
pub fn new(source: &'a str) -> Self {
Self::with_options(source, BASE_OPTIONS)
}
pub(crate) fn with_options(source: &'a str, options: Options) -> Self {
match frontmatter_end(source) {
Some((end, flavor)) => Self {
head: Some(Parser::new_ext(&source[..end], options | flavor)),
body: Parser::new_ext(&source[end..], options),
body_start: end,
},
None => Self {
head: None,
body: Parser::new_ext(source, options),
body_start: 0,
},
}
}
pub fn reference_definitions(&self) -> &RefDefs<'_> {
self.body.reference_definitions()
}
pub fn into_offset_iter(self) -> DocOffsetIter<'a> {
DocOffsetIter {
head: self.head.map(Parser::into_offset_iter),
body: self.body.into_offset_iter(),
shift: self.body_start,
}
}
pub(crate) fn into_events(self) -> impl Iterator<Item = Event<'a>> {
self.head.into_iter().flatten().chain(self.body)
}
}
pub struct DocOffsetIter<'a> {
head: Option<OffsetIter<'a>>,
body: OffsetIter<'a>,
shift: usize,
}
impl<'a> Iterator for DocOffsetIter<'a> {
type Item = (Event<'a>, Range<usize>);
#[inline]
fn next(&mut self) -> Option<Self::Item> {
if let Some(head) = &mut self.head {
match head.next() {
Some(item) => return Some(item),
None => self.head = None,
}
}
let (event, range) = self.body.next()?;
Some((event, range.start + self.shift..range.end + self.shift))
}
}
fn frontmatter_end(source: &str) -> Option<(usize, Options)> {
let (opener, rest) = source.split_once('\n')?;
let (flavor, closers): (Options, &[&str]) = match opener {
"---" => (Options::ENABLE_YAML_STYLE_METADATA_BLOCKS, &["---", "..."]),
"+++" => (Options::ENABLE_PLUSES_DELIMITED_METADATA_BLOCKS, &["+++"]),
_ => return None,
};
let mut end = opener.len() + 1;
for (i, line) in rest.split_inclusive('\n').enumerate() {
let text = line.strip_suffix('\n').unwrap_or(line);
let text = text.strip_suffix('\r').unwrap_or(text);
let closes = closers.iter().any(|closer| {
text.strip_prefix(closer)
.is_some_and(|tail| tail.bytes().all(|b| b == b' '))
});
if i == 0 && (closes || text.bytes().all(|b| b == b' ' || b == b'\t')) {
return None;
}
end += line.len();
if closes {
return Some((end, flavor));
}
}
None
}
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(),
}
}
#[inline]
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 DocParser::new(source).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
| BlockKind::MetadataBlock
)
})
}
pub fn footnote_definition_ranges(source: &str) -> Vec<(String, Range<usize>)> {
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 DocParser::new(source).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");
}
#[test]
fn metadata_block_range_covers_both_delimiter_lines() {
let src = "---\ntitle: Foo\n---\n\n# H\n";
let ranges = top_level_block_ranges(src);
assert_eq!(&src[ranges[0].clone()], "---\ntitle: Foo\n---\n");
assert_eq!(&src[ranges[1].clone()], "# H\n");
}
#[test]
fn a_mid_document_rule_pair_is_not_frontmatter() {
let src = "Intro.\n\n---\n## Section 2\n\nText.\n\n---\n## Section 3\n";
let ranges = top_level_block_ranges(src);
assert_eq!(&src[ranges[1].clone()], "---\n", "got: {ranges:?}");
assert_eq!(&src[ranges[2].clone()], "## Section 2\n\n");
}
#[test]
fn frontmatter_ends_past_its_closing_line() {
let yaml = Options::ENABLE_YAML_STYLE_METADATA_BLOCKS;
let toml = Options::ENABLE_PLUSES_DELIMITED_METADATA_BLOCKS;
for (src, end, flavor) in [
("---\ntitle: Foo\n---\n\nBody.\n", 19, yaml),
("---\ntitle: Foo\n...\n", 19, yaml),
("---\ntitle: Foo\n--- \n# H\n", 21, yaml),
("---\ntitle: Foo\n---", 18, yaml),
("---\na: 1\n\nb: 2\n---\n", 19, yaml),
("+++\ntitle = \"Foo\"\n+++\n---\n", 22, toml),
] {
assert_eq!(frontmatter_end(src), Some((end, flavor)), "got: {src:?}");
}
for src in [
"\n---\na: 1\n---\n",
" ---\na: 1\n---\n",
"----\na: 1\n----\n",
"--- yaml\na: 1\n---\n",
"---\n\na: 1\n---\n",
"---\n---\n",
"---\na: 1\n----\n",
"+++\na = 1\n---\n",
"---\na: 1\n",
"---",
"",
] {
assert_eq!(frontmatter_end(src), None, "got: {src:?}");
}
}
#[test]
fn frontmatter_end_agrees_with_pulldown_cmark() {
for src in [
"---\ntitle: Foo\n---\n\nBody.\n",
"---\ntitle: Foo\n...\n",
"---\ntitle: Foo\n--- \n# H\n",
"---\ntitle: Foo\n---",
"---\na: 1\n\nb: 2\n---\n",
"---\n\na: 1\n---\n",
"---\n---\n",
"---\na: 1\n----\n",
"---\na: 1\n ---\n---\n",
"---\na: 1\n\t---\n.... \n...\n",
"+++\na = 1\n+++\n",
"+++\na = 1\n++++\n",
"---\na: 1\n",
] {
let options = BASE_OPTIONS
| Options::ENABLE_YAML_STYLE_METADATA_BLOCKS
| Options::ENABLE_PLUSES_DELIMITED_METADATA_BLOCKS;
let pulldown =
Parser::new_ext(src, options)
.into_offset_iter()
.find_map(|(event, range)| match event {
Event::Start(Tag::MetadataBlock(_)) if range.start == 0 => {
Some(advance_past_newline(src, range.end))
}
_ => None,
});
let ours = frontmatter_end(src).map(|(end, _)| end);
assert_eq!(ours, pulldown, "got: {src:?}");
}
}
#[test]
fn only_the_byte_zero_block_is_frontmatter() {
let src = "---\nt: x\n---\n\n> ---\n> b\n\n---\n## Section\n\n---\n";
let mut events = DocParser::new(src).into_offset_iter();
let metadata: Vec<Range<usize>> = events
.by_ref()
.filter_map(|(event, range)| {
matches!(event, Event::Start(Tag::MetadataBlock(_))).then_some(range)
})
.collect();
assert_eq!(metadata.len(), 1, "got: {metadata:?}");
assert_eq!(metadata[0], 0..12);
let ranges = top_level_block_ranges(src);
let texts: Vec<&str> = ranges.iter().map(|r| &src[r.clone()]).collect();
assert_eq!(
texts,
[
"---\nt: x\n---\n",
"> ---\n> b\n\n",
"---\n",
"## Section\n\n",
"---\n"
],
);
}
#[test]
fn a_rule_is_not_a_metadata_block() {
let src = "---\ntitle: Foo\n\n# H\n";
let ranges = top_level_block_ranges(src);
assert_eq!(ranges.len(), 3, "got: {ranges:?}");
assert_eq!(&src[ranges[0].clone()], "---\n");
}
}