use std::borrow::Cow;
use pulldown_cmark::{CodeBlockKind, Options, Parser, Tag, TagEnd};
use crate::parse::tokenizer::{Alignment, Container, ContainerKind, Event, Spanned, Tokenizer};
#[derive(Clone, Copy, Debug, Default)]
pub struct PulldownTokenizer {
_private: (),
}
impl PulldownTokenizer {
#[must_use]
pub const fn new() -> PulldownTokenizer {
PulldownTokenizer { _private: () }
}
fn options() -> Options {
Options::ENABLE_TABLES | Options::ENABLE_STRIKETHROUGH
}
}
impl Tokenizer for PulldownTokenizer {
fn tokenize<'s>(&self, source: &'s str) -> Vec<Spanned<'s>> {
Parser::new_ext(source, PulldownTokenizer::options())
.into_offset_iter()
.filter_map(|(event, range)| convert(event).map(|event| (event, range)))
.collect()
}
}
#[allow(
clippy::too_many_lines,
reason = "one arm per event kind; splitting it would only move the arms"
)]
fn convert(event: pulldown_cmark::Event<'_>) -> Option<Event<'_>> {
use pulldown_cmark::Event as P;
Some(match event {
P::Start(tag) => Event::Start(container(tag)?),
P::End(tag) => Event::End(kind(tag)?),
P::Text(text) | P::InlineMath(text) | P::DisplayMath(text) => Event::Text(cow(text)),
P::Code(text) => Event::Code(cow(text)),
P::Html(html) => Event::Html(cow(html)),
P::InlineHtml(html) => Event::InlineHtml(cow(html)),
P::SoftBreak => Event::SoftBreak,
P::HardBreak => Event::HardBreak,
P::Rule => Event::Rule,
P::FootnoteReference(_) | P::TaskListMarker(_) => return None,
})
}
fn container(tag: Tag<'_>) -> Option<Container<'_>> {
Some(match tag {
Tag::Paragraph => Container::Paragraph,
Tag::Heading { level, .. } => Container::Heading {
level: heading_level(level),
},
Tag::BlockQuote(_) => Container::Blockquote,
Tag::CodeBlock(CodeBlockKind::Fenced(info)) => Container::CodeBlock {
info: Some(cow(info)),
},
Tag::CodeBlock(CodeBlockKind::Indented) => Container::CodeBlock { info: None },
Tag::List(start) => Container::List {
ordered: start.is_some(),
start,
},
Tag::Item => Container::Item,
Tag::Table(alignments) => Container::Table {
alignments: alignments.into_iter().map(alignment).collect(),
},
Tag::TableHead => Container::TableHead,
Tag::TableRow => Container::TableRow,
Tag::TableCell => Container::TableCell,
Tag::Emphasis => Container::Emphasis,
Tag::Strong => Container::Strong,
Tag::Strikethrough => Container::Strikethrough,
Tag::Link {
dest_url, title, ..
} => Container::Link {
destination: cow(dest_url),
title: cow(title),
},
Tag::Image {
dest_url, title, ..
} => Container::Image {
destination: cow(dest_url),
title: cow(title),
},
Tag::HtmlBlock
| Tag::FootnoteDefinition(_)
| Tag::DefinitionList
| Tag::DefinitionListTitle
| Tag::DefinitionListDefinition
| Tag::Superscript
| Tag::Subscript
| Tag::MetadataBlock(_) => return None,
})
}
fn kind(tag: TagEnd) -> Option<ContainerKind> {
Some(match tag {
TagEnd::Paragraph => ContainerKind::Paragraph,
TagEnd::Heading(_) => ContainerKind::Heading,
TagEnd::BlockQuote(_) => ContainerKind::Blockquote,
TagEnd::CodeBlock => ContainerKind::CodeBlock,
TagEnd::List(_) => ContainerKind::List,
TagEnd::Item => ContainerKind::Item,
TagEnd::Table => ContainerKind::Table,
TagEnd::TableHead => ContainerKind::TableHead,
TagEnd::TableRow => ContainerKind::TableRow,
TagEnd::TableCell => ContainerKind::TableCell,
TagEnd::Emphasis => ContainerKind::Emphasis,
TagEnd::Strong => ContainerKind::Strong,
TagEnd::Strikethrough => ContainerKind::Strikethrough,
TagEnd::Link => ContainerKind::Link,
TagEnd::Image => ContainerKind::Image,
TagEnd::HtmlBlock
| TagEnd::FootnoteDefinition
| TagEnd::DefinitionList
| TagEnd::DefinitionListTitle
| TagEnd::DefinitionListDefinition
| TagEnd::Superscript
| TagEnd::Subscript
| TagEnd::MetadataBlock(_) => return None,
})
}
fn heading_level(level: pulldown_cmark::HeadingLevel) -> u8 {
use pulldown_cmark::HeadingLevel as H;
match level {
H::H1 => 1,
H::H2 => 2,
H::H3 => 3,
H::H4 => 4,
H::H5 => 5,
H::H6 => 6,
}
}
fn alignment(alignment: pulldown_cmark::Alignment) -> Alignment {
use pulldown_cmark::Alignment as A;
match alignment {
A::None => Alignment::None,
A::Left => Alignment::Left,
A::Center => Alignment::Center,
A::Right => Alignment::Right,
}
}
fn cow(text: pulldown_cmark::CowStr<'_>) -> Cow<'_, str> {
match text {
pulldown_cmark::CowStr::Borrowed(text) => Cow::Borrowed(text),
other => Cow::Owned(other.into_string()),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn events(source: &str) -> Vec<Event<'_>> {
PulldownTokenizer::new()
.tokenize(source)
.into_iter()
.map(|(event, _)| event)
.collect()
}
#[test]
fn a_paragraph_is_one_container_around_its_text() {
assert_eq!(
events("hello"),
[
Event::Start(Container::Paragraph),
Event::Text(Cow::Borrowed("hello")),
Event::End(ContainerKind::Paragraph),
]
);
}
#[test]
fn ranges_cover_delimiters_so_markers_can_be_read_back() {
let source = "*a*";
let spans = PulldownTokenizer::new().tokenize(source);
let (_, range) = spans
.iter()
.find(|(event, _)| matches!(event, Event::Start(Container::Emphasis)))
.expect("emphasis is produced");
assert_eq!(source.get(range.clone()), Some("*a*"));
}
#[test]
fn tables_and_strikethrough_are_on_and_nothing_else_is() {
assert!(
events("| a |\n| - |\n| b |")
.iter()
.any(|event| matches!(event, Event::Start(Container::Table { .. })))
);
assert!(
events("~~x~~")
.iter()
.any(|event| matches!(event, Event::Start(Container::Strikethrough)))
);
assert!(
events("# t {#id}")
.iter()
.any(|event| matches!(event, Event::Text(text) if text.contains("{#id}")))
);
}
#[test]
fn a_fence_carries_its_info_string_verbatim() {
let found = events("```ruby this is a test\nx\n```")
.into_iter()
.find_map(|event| match event {
Event::Start(Container::CodeBlock { info }) => info,
_ => None,
});
assert_eq!(found.as_deref(), Some("ruby this is a test"));
}
#[test]
fn every_start_is_balanced_by_an_end() {
let source =
"# h\n\n> q\n\n* i\n\n| a |\n| - |\n| b |\n\n`c` *e* **s** ~~d~~ [l](/u) ";
let mut depth = 0i32;
for event in events(source) {
match event {
Event::Start(_) => depth += 1,
Event::End(_) => depth -= 1,
_ => {}
}
assert!(depth >= 0, "an end arrived with nothing open");
}
assert_eq!(depth, 0, "a container was left open");
}
}