mod unflatten;
mod from_events;
mod to_events;
mod test_readme {
#![doc = include_str!("../README.md")]
}
use pulldown_cmark::{self as md, CowStr, Event};
pub use pulldown_cmark::HeadingLevel;
#[derive(Debug, Clone, PartialEq)]
pub enum Block {
Paragraph(Inlines),
List(Vec<ListItem>),
Heading(HeadingLevel, Inlines),
CodeBlock {
kind: CodeBlockKind,
code: String,
},
BlockQuote {
kind: Option<md::BlockQuoteKind>,
blocks: Vec<Block>,
},
Table {
alignments: Vec<md::Alignment>,
headers: Vec<Inlines>,
rows: Vec<Vec<Inlines>>,
},
Rule,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Inlines(pub Vec<Inline>);
#[derive(Debug, Clone, PartialEq)]
pub struct ListItem(pub Vec<Block>);
#[derive(Debug, Clone, PartialEq)]
pub enum Inline {
Text(String),
Emphasis(Inlines),
Strong(Inlines),
Strikethrough(Inlines),
Code(String),
Link {
link_type: md::LinkType,
dest_url: String,
title: String,
id: String,
content_text: Inlines,
},
SoftBreak,
HardBreak,
}
#[derive(Debug, Clone, PartialEq)]
pub enum CodeBlockKind {
Fenced(String),
Indented,
}
pub fn markdown_to_ast(input: &str) -> Vec<Block> {
let events = markdown_to_events(input);
return events_to_ast(events);
}
pub fn ast_to_markdown(blocks: &[Block]) -> String {
let events = ast_to_events(blocks);
return events_to_markdown(events);
}
pub fn events_to_markdown<'e, I: IntoIterator<Item = Event<'e>>>(
events: I,
) -> String {
let mut string = String::new();
let options = default_to_markdown_options();
let _: pulldown_cmark_to_cmark::State =
pulldown_cmark_to_cmark::cmark_with_options(
events.into_iter(),
&mut string,
options,
)
.expect("error converting Event sequent to Markdown string");
string
}
pub fn ast_to_events(blocks: &[Block]) -> Vec<Event> {
let mut events: Vec<Event> = Vec::new();
for block in blocks {
let events = &mut events;
crate::to_events::block_to_events(&block, events);
}
events
}
pub fn events_to_ast<'i, I: IntoIterator<Item = Event<'i>>>(
events: I,
) -> Vec<Block> {
let events =
unflatten::parse_markdown_to_unflattened_events(events.into_iter());
crate::from_events::ast_events_to_ast(events)
}
pub fn markdown_to_events<'i>(
input: &'i str,
) -> impl Iterator<Item = Event<'i>> {
let mut options = md::Options::empty();
options.insert(md::Options::ENABLE_STRIKETHROUGH);
options.insert(md::Options::ENABLE_TABLES);
md::Parser::new_ext(input, options)
}
pub fn canonicalize(input: &str) -> String {
let ast = markdown_to_ast(input);
return ast_to_markdown(&ast);
}
fn default_to_markdown_options() -> pulldown_cmark_to_cmark::Options<'static> {
pulldown_cmark_to_cmark::Options {
code_block_token_count: 3,
..pulldown_cmark_to_cmark::Options::default()
}
}
impl Inline {
pub fn plain_text<S: Into<String>>(s: S) -> Self {
Inline::Text(s.into())
}
pub fn emphasis(inline: Inline) -> Self {
Inline::Emphasis(Inlines(vec![inline]))
}
pub fn strong(inline: Inline) -> Self {
Inline::Strong(Inlines(vec![inline]))
}
pub fn strikethrough(inline: Inline) -> Self {
Inline::Strikethrough(Inlines(vec![inline]))
}
pub fn code<S: Into<String>>(s: S) -> Self {
Inline::Code(s.into())
}
}
impl Inlines {
pub fn plain_text<S: Into<String>>(inline: S) -> Self {
return Inlines(vec![Inline::Text(inline.into())]);
}
}
impl Block {
pub fn plain_text_paragraph<S: Into<String>>(inline: S) -> Self {
return Block::Paragraph(Inlines(vec![Inline::Text(inline.into())]));
}
pub fn paragraph(text: Vec<Inline>) -> Block {
Block::Paragraph(Inlines(text))
}
}
impl ListItem {
pub fn plain_text<S: Into<String>>(inline: S) -> Self {
return ListItem(vec![Block::Paragraph(Inlines(vec![Inline::Text(
inline.into(),
)]))]);
}
}
impl CodeBlockKind {
pub fn info_string(&self) -> Option<&str> {
match self {
CodeBlockKind::Fenced(info_string) => Some(info_string.as_str()),
CodeBlockKind::Indented => None,
}
}
pub(crate) fn from_pulldown_cmark(kind: md::CodeBlockKind) -> Self {
match kind {
md::CodeBlockKind::Indented => CodeBlockKind::Indented,
md::CodeBlockKind::Fenced(info_string) => {
CodeBlockKind::Fenced(info_string.to_string())
},
}
}
pub(crate) fn to_pulldown_cmark<'s>(&'s self) -> md::CodeBlockKind<'s> {
match self {
CodeBlockKind::Fenced(info) => {
md::CodeBlockKind::Fenced(CowStr::from(info.as_str()))
},
CodeBlockKind::Indented => md::CodeBlockKind::Indented,
}
}
}
impl IntoIterator for Inlines {
type Item = Inline;
type IntoIter = std::vec::IntoIter<Inline>;
fn into_iter(self) -> Self::IntoIter {
let Inlines(vec) = self;
vec.into_iter()
}
}
#[test]
fn test_markdown_to_ast() {
use indoc::indoc;
use pretty_assertions::assert_eq;
assert_eq!(
markdown_to_ast("hello"),
vec![Block::paragraph(vec![Inline::Text("hello".into())])]
);
assert_eq!(
markdown_to_ast("*hello*"),
vec![Block::paragraph(vec![Inline::emphasis(Inline::Text(
"hello".into()
))])]
);
assert_eq!(
markdown_to_ast("**hello**"),
vec![Block::paragraph(vec![Inline::strong(Inline::Text(
"hello".into()
))])]
);
assert_eq!(
markdown_to_ast("~~hello~~"),
vec![Block::paragraph(vec![Inline::strikethrough(Inline::Text(
"hello".into()
))])]
);
assert_eq!(
markdown_to_ast("**`strong code`**"),
vec![Block::paragraph(vec![Inline::strong(Inline::Code(
"strong code".into()
))])]
);
assert_eq!(
markdown_to_ast("~~`foo`~~"),
vec![Block::paragraph(vec![Inline::strikethrough(Inline::Code(
"foo".into()
))])]
);
assert_eq!(
markdown_to_ast("**[example](example.com)**"),
vec![Block::paragraph(vec![Inline::strong(Inline::Link {
link_type: md::LinkType::Inline,
dest_url: "example.com".into(),
title: String::new(),
id: String::new(),
content_text: Inlines(vec![Inline::Text("example".into())]),
})])]
);
assert_eq!(
markdown_to_ast("_~~**`foo`**~~_"),
vec![Block::paragraph(vec![Inline::emphasis(
Inline::strikethrough(Inline::strong(Inline::Code("foo".into())))
)])]
);
assert_eq!(
markdown_to_ast("* hello"),
vec![Block::List(vec![ListItem(vec![Block::paragraph(vec![
Inline::Text("hello".into())
])])])]
);
assert_eq!(
markdown_to_ast("* *hello*"),
vec![Block::List(vec![ListItem(vec![Block::paragraph(vec![
Inline::emphasis(Inline::Text("hello".into()))
])])])]
);
assert_eq!(
markdown_to_ast("* **hello**"),
vec![Block::List(vec![ListItem(vec![Block::paragraph(vec![
Inline::strong(Inline::Text("hello".into()))
])])])]
);
assert_eq!(
markdown_to_ast("* ~~hello~~"),
vec![Block::List(vec![ListItem(vec![Block::paragraph(vec![
Inline::strikethrough(Inline::Text("hello".into()),)
])])])]
);
let input = "\
* And **bold** text.
* With nested list items.
* `md2nb` supports nested lists up to three levels deep.
";
let ast = vec![Block::List(vec![ListItem(vec![
Block::paragraph(vec![
Inline::plain_text("And "),
Inline::strong(Inline::plain_text("bold")),
Inline::plain_text(" text."),
]),
Block::List(vec![ListItem(vec![
Block::paragraph(vec![Inline::plain_text(
"With nested list items.",
)]),
Block::List(vec![ListItem(vec![Block::paragraph(vec![
Inline::code("md2nb"),
Inline::plain_text(
" supports nested lists up to three levels deep.",
),
])])]),
])]),
])])];
assert_eq!(markdown_to_ast(input), ast);
assert_eq!(
markdown_to_events(input).collect::<Vec<_>>(),
ast_to_events(&ast)
);
assert_eq!(
markdown_to_ast(indoc!(
"
* hello
world
"
)),
vec![Block::List(vec![ListItem(vec![
Block::paragraph(vec![Inline::Text("hello".into())]),
Block::paragraph(vec![Inline::Text("world".into())])
])])]
);
#[rustfmt::skip]
assert_eq!(
markdown_to_ast(indoc!(
"
# Example
* A
- A.A
hello world
* *A.A.A*
"
)),
vec![
Block::Heading(
HeadingLevel::H1,
Inlines(vec![Inline::Text("Example".into())])
),
Block::List(vec![
ListItem(vec![
Block::paragraph(vec![Inline::Text("A".into())]),
Block::List(vec![
ListItem(vec![
Block::paragraph(vec![Inline::Text("A.A".into())]),
Block::paragraph(vec![Inline::Text("hello world".into())]),
Block::List(vec![
ListItem(vec![
Block::paragraph(vec![
Inline::emphasis(
Inline::Text(
"A.A.A".into()),
)
])
])
])
])
])
])
])
]
);
#[rustfmt::skip]
assert_eq!(
markdown_to_ast(indoc!(
"
* A
- A.A
* A.A.A
- A.B
- A.C
"
)),
vec![
Block::List(vec![
ListItem(vec![
Block::paragraph(vec![Inline::Text("A".into())]),
Block::List(vec![
ListItem(vec![
Block::paragraph(vec![Inline::Text("A.A".into())]),
Block::List(vec![ListItem(vec![
Block::paragraph(vec![Inline::Text("A.A.A".into())]),
])])
]),
ListItem(vec![
Block::paragraph(vec![Inline::Text("A.B".into())]),
]),
ListItem(vec![
Block::paragraph(vec![Inline::Text("A.C".into())]),
])
])
])
])
]
);
#[rustfmt::skip]
assert_eq!(
markdown_to_ast(indoc!(
"
# Example
* A
- A.A
- A.B
* A.C
"
)),
vec![
Block::Heading(
HeadingLevel::H1,
Inlines(vec![Inline::Text("Example".into())])
),
Block::List(vec![
ListItem(vec![
Block::paragraph(vec![Inline::Text("A".into())]),
Block::List(vec![
ListItem(vec![
Block::paragraph(vec![Inline::Text("A.A".into())]),
]),
ListItem(vec![
Block::paragraph(vec![Inline::Text("A.B".into())]),
]),
]),
Block::List(vec![
ListItem(vec![
Block::paragraph(vec![Inline::Text("A.C".into())])
])
]),
]),
])
]
);
#[rustfmt::skip]
assert_eq!(
markdown_to_ast(indoc!(
"
* A
- A.A
- A.B
separate paragraph
- A.C
"
)),
vec![
Block::List(vec![
ListItem(vec![
Block::paragraph(vec![Inline::Text("A".into())]),
Block::List(vec![
ListItem(vec![
Block::paragraph(vec![Inline::Text("A.A".into())]),
]),
ListItem(vec![
Block::paragraph(vec![Inline::Text("A.B".into())]),
Block::paragraph(vec![Inline::Text("separate paragraph".into())]),
]),
ListItem(vec![
Block::paragraph(vec![Inline::Text("A.C".into())]),
])
])
])
])
]
);
#[rustfmt::skip]
assert_eq!(
markdown_to_ast(indoc!(
"
# Example
* A
- A.A
* A.A.A
**soft break**
- A.B
separate paragraph
- A.C
"
)),
vec![
Block::Heading(
HeadingLevel::H1,
Inlines(vec![Inline::Text("Example".into())])
),
Block::List(vec![
ListItem(vec![
Block::paragraph(vec![Inline::Text("A".into())]),
Block::List(vec![
ListItem(vec![
Block::paragraph(vec![Inline::Text("A.A".into())]),
Block::List(vec![
ListItem(vec![
Block::paragraph(vec![
Inline::Text("A.A.A".into()),
Inline::SoftBreak,
Inline::strong(
Inline::Text("soft break".into()),
)
]),
])
]),
]),
ListItem(vec![
Block::paragraph(vec![Inline::Text("A.B".into())]),
Block::paragraph(vec![Inline::Text("separate paragraph".into())]),
]),
ListItem(vec![
Block::paragraph(vec![Inline::Text("A.C".into())]),
]),
])
])
])
]
);
}
#[test]
fn test_ast_to_markdown() {
use indoc::indoc;
assert_eq!(
ast_to_markdown(&[Block::paragraph(vec![Inline::Text(
"hello".into()
)])]),
"hello"
);
assert_eq!(
ast_to_markdown(&[Block::List(vec![ListItem(vec![
Block::paragraph(vec![Inline::Text("hello".into())]),
Block::paragraph(vec![Inline::Text("world".into())])
])])]),
indoc!(
"
* hello
world"
),
)
}
#[test]
fn test_md_documents_roundtrip() {
let kitchen_sink_md =
include_str!("../../md2nb/docs/examples/kitchen-sink.md");
let kitchen_sink_md = kitchen_sink_md
.replace("\n \"This is an indented code block.\"\n", "")
.replace("\nThis is a [shortcut] reference link.\n", "")
.replace("\nThis is a [full reference][full reference] link.\n", "")
.replace("\n[full reference]: https://example.org\n", "")
.replace("[shortcut]: https://example.org\n", "");
assert_roundtrip(&kitchen_sink_md);
let readme = include_str!("../../../README.md");
assert_roundtrip(readme);
}
#[cfg(test)]
fn assert_roundtrip(markdown: &str) {
use pretty_assertions::assert_eq;
let original_events: Vec<Event> = markdown_to_events(markdown).collect();
let ast: Vec<Block> = events_to_ast(original_events.clone());
let processed_events: Vec<Event> = ast_to_events(&ast);
assert_eq!(processed_events, original_events);
assert_eq!(ast_to_markdown(&ast), markdown);
}