#![deny(rust_2018_idioms)]
use std::{
borrow::{Borrow, Cow},
collections::HashSet,
fmt,
};
use pulldown_cmark::{Alignment as TableAlignment, Event, HeadingLevel, LinkType};
#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum Alignment {
None,
Left,
Center,
Right,
}
impl<'a> From<&'a TableAlignment> for Alignment {
fn from(s: &'a TableAlignment) -> Self {
match *s {
TableAlignment::None => Alignment::None,
TableAlignment::Left => Alignment::Left,
TableAlignment::Center => Alignment::Center,
TableAlignment::Right => Alignment::Right,
}
}
}
#[derive(Clone, Default, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct State<'a> {
pub newlines_before_start: usize,
pub list_stack: Vec<Option<u64>>,
pub padding: Vec<Cow<'a, str>>,
pub table_alignments: Vec<Alignment>,
pub table_headers: Vec<String>,
pub text_for_header: Option<String>,
pub is_in_code_block: bool,
pub last_was_html: bool,
pub last_was_text_without_trailing_newline: bool,
pub current_shortcut_text: Option<String>,
pub shortcuts: Vec<(String, String, String)>,
}
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct Options<'a> {
pub newlines_after_headline: usize,
pub newlines_after_paragraph: usize,
pub newlines_after_codeblock: usize,
pub newlines_after_table: usize,
pub newlines_after_rule: usize,
pub newlines_after_list: usize,
pub newlines_after_blockquote: usize,
pub newlines_after_rest: usize,
pub code_block_token_count: usize,
pub code_block_token: char,
pub list_token: char,
pub ordered_list_token: char,
pub increment_ordered_list_bullets: bool,
pub emphasis_token: char,
pub strong_token: &'a str,
}
const DEFAULT_OPTIONS: Options<'_> = Options {
newlines_after_headline: 2,
newlines_after_paragraph: 2,
newlines_after_codeblock: 2,
newlines_after_table: 2,
newlines_after_rule: 2,
newlines_after_list: 2,
newlines_after_blockquote: 2,
newlines_after_rest: 1,
code_block_token_count: 4,
code_block_token: '`',
list_token: '*',
ordered_list_token: '.',
increment_ordered_list_bullets: false,
emphasis_token: '*',
strong_token: "**",
};
impl<'a> Default for Options<'a> {
fn default() -> Self {
DEFAULT_OPTIONS
}
}
impl<'a> Options<'a> {
pub fn special_characters(&self) -> Cow<'static, str> {
const BASE: &str = "#\\_*<>`|[]";
if DEFAULT_OPTIONS.code_block_token == self.code_block_token
&& DEFAULT_OPTIONS.list_token == self.list_token
&& DEFAULT_OPTIONS.emphasis_token == self.emphasis_token
&& DEFAULT_OPTIONS.strong_token == self.strong_token
{
BASE.into()
} else {
let mut s = String::from(BASE);
s.push(self.code_block_token);
s.push(self.list_token);
s.push(self.emphasis_token);
s.push_str(self.strong_token);
s.into()
}
}
}
pub fn cmark_resume_with_options<'a, I, E, F>(
events: I,
mut formatter: F,
state: Option<State<'static>>,
options: Options<'_>,
) -> Result<State<'static>, fmt::Error>
where
I: Iterator<Item = E>,
E: Borrow<Event<'a>>,
F: fmt::Write,
{
let mut state = state.unwrap_or_default();
fn padding<F>(f: &mut F, p: &[Cow<'_, str>]) -> fmt::Result
where
F: fmt::Write,
{
for padding in p {
write!(f, "{}", padding)?;
}
Ok(())
}
fn consume_newlines<F>(f: &mut F, s: &mut State<'_>) -> fmt::Result
where
F: fmt::Write,
{
while s.newlines_before_start != 0 {
s.newlines_before_start -= 1;
f.write_char('\n')?;
padding(f, &s.padding)?;
}
Ok(())
}
fn escape_leading_special_characters<'a>(
t: &'a str,
is_in_block_quote: bool,
options: &Options<'a>,
) -> Cow<'a, str> {
if is_in_block_quote || t.is_empty() {
return Cow::Borrowed(t);
}
let first = t.chars().next().expect("at least one char");
if options.special_characters().contains(first) {
let mut s = String::with_capacity(t.len() + 1);
s.push('\\');
s.push(first);
s.push_str(&t[1..]);
Cow::Owned(s)
} else {
Cow::Borrowed(t)
}
}
fn print_text_without_trailing_newline<F>(t: &str, f: &mut F, p: &[Cow<'_, str>]) -> fmt::Result
where
F: fmt::Write,
{
if t.contains('\n') {
let line_count = t.split('\n').count();
for (tid, token) in t.split('\n').enumerate() {
f.write_str(token).and(if tid + 1 == line_count {
Ok(())
} else {
f.write_char('\n').and(padding(f, p))
})?;
}
Ok(())
} else {
f.write_str(t)
}
}
fn padding_of(l: Option<u64>) -> Cow<'static, str> {
match l {
None => " ".into(),
Some(n) => format!("{}. ", n).chars().map(|_| ' ').collect::<String>().into(),
}
}
for event in events {
use pulldown_cmark::{CodeBlockKind, Event::*, Tag::*};
let event = event.borrow();
if state.last_was_html {
match event {
Html(_) => { }
Text(_) => { }
End(_) => { }
SoftBreak => { }
_ => {
formatter.write_char('\n')?;
}
}
}
state.last_was_html = false;
let last_was_text_without_trailing_newline = state.last_was_text_without_trailing_newline;
state.last_was_text_without_trailing_newline = false;
match *event {
Rule => {
consume_newlines(&mut formatter, &mut state)?;
if state.newlines_before_start < options.newlines_after_rule {
state.newlines_before_start = options.newlines_after_rule;
}
formatter.write_str("---")
}
Code(ref text) => {
if let Some(shortcut_text) = state.current_shortcut_text.as_mut() {
shortcut_text.push('`');
shortcut_text.push_str(text);
shortcut_text.push('`');
}
if let Some(text_for_header) = state.text_for_header.as_mut() {
text_for_header.push('`');
text_for_header.push_str(text);
text_for_header.push('`');
}
if text.chars().all(|ch| ch == ' ') {
write!(formatter, "`{text}`")
} else {
let backticks = "`".repeat(count_consecutive_backticks(text) + 1);
let space = match text.as_bytes() {
&[b'`', ..] | &[.., b'`'] => " ", &[b' ', .., b' '] => " ", _ => "", };
write!(formatter, "{backticks}{space}{text}{space}{backticks}")
}
}
Start(ref tag) => {
if let List(ref list_type) = *tag {
state.list_stack.push(*list_type);
if state.list_stack.len() > 1 && state.newlines_before_start < options.newlines_after_rest {
state.newlines_before_start = options.newlines_after_rest;
}
}
let consumed_newlines = state.newlines_before_start != 0;
consume_newlines(&mut formatter, &mut state)?;
match tag {
Item => match state.list_stack.last_mut() {
Some(inner) => {
state.padding.push(padding_of(*inner));
match inner {
Some(n) => {
let bullet_number = *n;
if options.increment_ordered_list_bullets {
*n += 1;
}
write!(formatter, "{}{} ", bullet_number, options.ordered_list_token)
}
None => write!(formatter, "{} ", options.list_token),
}
}
None => Ok(()),
},
Table(ref alignments) => {
state.table_alignments = alignments.iter().map(From::from).collect();
Ok(())
}
TableHead => Ok(()),
TableRow => Ok(()),
TableCell => {
state.text_for_header = Some(String::new());
formatter.write_char('|')
}
Link(LinkType::Autolink | LinkType::Email, ..) => formatter.write_char('<'),
Link(LinkType::Shortcut, ..) => {
state.current_shortcut_text = Some(String::new());
formatter.write_char('[')
}
Link(..) => formatter.write_char('['),
Image(..) => formatter.write_str("!["),
Emphasis => formatter.write_char(options.emphasis_token),
Strong => formatter.write_str(options.strong_token),
FootnoteDefinition(ref name) => write!(formatter, "[^{}]: ", name),
Paragraph => Ok(()),
Heading(level, _, _) => {
match level {
HeadingLevel::H1 => formatter.write_str("#"),
HeadingLevel::H2 => formatter.write_str("##"),
HeadingLevel::H3 => formatter.write_str("###"),
HeadingLevel::H4 => formatter.write_str("####"),
HeadingLevel::H5 => formatter.write_str("#####"),
HeadingLevel::H6 => formatter.write_str("######"),
}?;
formatter.write_char(' ')
}
BlockQuote => {
state.padding.push(" > ".into());
state.newlines_before_start = 1;
if consumed_newlines {
formatter.write_str(" > ")
} else {
formatter.write_char('\n').and(padding(&mut formatter, &state.padding))
}
}
CodeBlock(CodeBlockKind::Indented) => {
state.is_in_code_block = true;
for _ in 0..options.code_block_token_count {
formatter.write_char(options.code_block_token)?;
}
formatter.write_char('\n').and(padding(&mut formatter, &state.padding))
}
CodeBlock(CodeBlockKind::Fenced(ref info)) => {
state.is_in_code_block = true;
let s = if !consumed_newlines {
formatter
.write_char('\n')
.and_then(|_| padding(&mut formatter, &state.padding))
} else {
Ok(())
};
s.and_then(|_| {
for _ in 0..options.code_block_token_count {
formatter.write_char(options.code_block_token)?;
}
Ok(())
})
.and_then(|_| formatter.write_str(info))
.and_then(|_| formatter.write_char('\n'))
.and_then(|_| padding(&mut formatter, &state.padding))
}
List(_) => Ok(()),
Strikethrough => formatter.write_str("~~"),
}
}
End(ref tag) => match tag {
Link(LinkType::Autolink | LinkType::Email, ..) => formatter.write_char('>'),
Link(LinkType::Shortcut, ref uri, ref title) => {
if let Some(shortcut_text) = state.current_shortcut_text.take() {
state
.shortcuts
.push((shortcut_text, uri.to_string(), title.to_string()));
}
formatter.write_char(']')
}
Image(_, ref uri, ref title) | Link(_, ref uri, ref title) => {
close_link(uri, title, &mut formatter, LinkType::Inline)
}
Emphasis => formatter.write_char(options.emphasis_token),
Strong => formatter.write_str(options.strong_token),
Heading(_, id, classes) => {
let emit_braces = id.is_some() || !classes.is_empty();
if emit_braces {
formatter.write_str(" {")?;
}
if let Some(id_str) = id {
formatter.write_char('#')?;
formatter.write_str(id_str)?;
if !classes.is_empty() {
formatter.write_char(' ')?;
}
}
for (idx, class) in classes.iter().enumerate() {
formatter.write_char('.')?;
formatter.write_str(class)?;
if idx < classes.len() - 1 {
formatter.write_char(' ')?;
}
}
if emit_braces {
formatter.write_char('}')?;
}
if state.newlines_before_start < options.newlines_after_headline {
state.newlines_before_start = options.newlines_after_headline;
}
Ok(())
}
Paragraph => {
if state.newlines_before_start < options.newlines_after_paragraph {
state.newlines_before_start = options.newlines_after_paragraph;
}
Ok(())
}
CodeBlock(_) => {
if state.newlines_before_start < options.newlines_after_codeblock {
state.newlines_before_start = options.newlines_after_codeblock;
}
state.is_in_code_block = false;
if last_was_text_without_trailing_newline {
formatter.write_char('\n')?;
}
for _ in 0..options.code_block_token_count {
formatter.write_char(options.code_block_token)?;
}
Ok(())
}
Table(_) => {
if state.newlines_before_start < options.newlines_after_table {
state.newlines_before_start = options.newlines_after_table;
}
state.table_alignments.clear();
state.table_headers.clear();
Ok(())
}
TableCell => {
state.table_headers.push(
state
.text_for_header
.take()
.filter(|s| !s.is_empty())
.unwrap_or_else(|| " ".into()),
);
Ok(())
}
ref t @ TableRow | ref t @ TableHead => {
if state.newlines_before_start < options.newlines_after_rest {
state.newlines_before_start = options.newlines_after_rest;
}
formatter.write_char('|')?;
if let TableHead = t {
formatter
.write_char('\n')
.and(padding(&mut formatter, &state.padding))?;
for (alignment, name) in state.table_alignments.iter().zip(state.table_headers.iter()) {
formatter.write_char('|')?;
let last_minus_one = name.chars().count().saturating_sub(1);
for c in 0..name.len() {
formatter.write_char(
if (c == 0 && (alignment == &Alignment::Center || alignment == &Alignment::Left))
|| (c == last_minus_one
&& (alignment == &Alignment::Center || alignment == &Alignment::Right))
{
':'
} else {
'-'
},
)?;
}
}
formatter.write_char('|')?;
}
Ok(())
}
Item => {
state.padding.pop();
if state.newlines_before_start < options.newlines_after_rest {
state.newlines_before_start = options.newlines_after_rest;
}
Ok(())
}
List(_) => {
state.list_stack.pop();
if state.list_stack.is_empty() && state.newlines_before_start < options.newlines_after_list {
state.newlines_before_start = options.newlines_after_list;
}
Ok(())
}
BlockQuote => {
state.padding.pop();
if state.newlines_before_start < options.newlines_after_blockquote {
state.newlines_before_start = options.newlines_after_blockquote;
}
Ok(())
}
FootnoteDefinition(_) => Ok(()),
Strikethrough => formatter.write_str("~~"),
},
HardBreak => formatter.write_str(" \n").and(padding(&mut formatter, &state.padding)),
SoftBreak => formatter.write_char('\n').and(padding(&mut formatter, &state.padding)),
Text(ref text) => {
if let Some(shortcut_text) = state.current_shortcut_text.as_mut() {
shortcut_text.push_str(text);
}
if let Some(text_for_header) = state.text_for_header.as_mut() {
text_for_header.push_str(text)
}
consume_newlines(&mut formatter, &mut state)?;
state.last_was_text_without_trailing_newline = !text.ends_with('\n');
print_text_without_trailing_newline(
&escape_leading_special_characters(text, state.is_in_code_block, &options),
&mut formatter,
&state.padding,
)
}
Html(ref text) => {
state.last_was_html = true;
consume_newlines(&mut formatter, &mut state)?;
print_text_without_trailing_newline(text, &mut formatter, &state.padding)
}
FootnoteReference(ref name) => write!(formatter, "[^{}]", name),
TaskListMarker(checked) => {
let check = if checked { "x" } else { " " };
write!(formatter, "[{}] ", check)
}
}?
}
Ok(state)
}
pub fn cmark_resume<'a, I, E, F>(
events: I,
formatter: F,
state: Option<State<'static>>,
) -> Result<State<'static>, fmt::Error>
where
I: Iterator<Item = E>,
E: Borrow<Event<'a>>,
F: fmt::Write,
{
cmark_resume_with_options(events, formatter, state, Options::default())
}
fn close_link<F>(uri: &str, title: &str, f: &mut F, link_type: LinkType) -> fmt::Result
where
F: fmt::Write,
{
let separator = match link_type {
LinkType::Shortcut => ": ",
_ => "(",
};
if uri.contains(' ') {
write!(f, "]{}<{uri}>", separator, uri = uri)?;
} else {
write!(f, "]{}{uri}", separator, uri = uri)?;
}
if !title.is_empty() {
write!(f, " \"{title}\"", title = title)?;
}
if link_type != LinkType::Shortcut {
f.write_char(')')?;
}
Ok(())
}
impl<'a> State<'a> {
pub fn finalize<F>(mut self, mut formatter: F) -> Result<Self, fmt::Error>
where
F: fmt::Write,
{
if self.shortcuts.is_empty() {
return Ok(self);
}
formatter.write_str("\n")?;
let mut written_shortcuts = HashSet::new();
for shortcut in self.shortcuts.drain(..) {
if written_shortcuts.contains(&shortcut) {
continue;
}
write!(formatter, "\n[{}", shortcut.0)?;
close_link(&shortcut.1, &shortcut.2, &mut formatter, LinkType::Shortcut)?;
written_shortcuts.insert(shortcut);
}
Ok(self)
}
}
pub fn cmark_with_options<'a, I, E, F>(
events: I,
mut formatter: F,
options: Options<'_>,
) -> Result<State<'static>, fmt::Error>
where
I: Iterator<Item = E>,
E: Borrow<Event<'a>>,
F: fmt::Write,
{
let state = cmark_resume_with_options(events, &mut formatter, Default::default(), options)?;
state.finalize(formatter)
}
pub fn cmark<'a, I, E, F>(events: I, mut formatter: F) -> Result<State<'static>, fmt::Error>
where
I: Iterator<Item = E>,
E: Borrow<Event<'a>>,
F: fmt::Write,
{
cmark_with_options(events, &mut formatter, Default::default())
}
fn count_consecutive_backticks(text: &str) -> usize {
let mut in_backticks = false;
let mut max_backticks = 0;
let mut cur_backticks = 0;
for ch in text.chars() {
if ch == '`' {
cur_backticks += 1;
in_backticks = true;
} else if in_backticks {
max_backticks = max_backticks.max(cur_backticks);
cur_backticks = 0;
in_backticks = false;
}
}
max_backticks.max(cur_backticks)
}
#[cfg(test)]
mod count_consecutive_backticks {
use super::count_consecutive_backticks;
#[test]
fn happens_in_the_entire_string() {
assert_eq!(
count_consecutive_backticks("``a```b``"),
3,
"the highest seen consecutive segment of backticks counts"
);
assert_eq!(
count_consecutive_backticks("```a``b`"),
3,
"it can't be downgraded later"
);
}
}