blob: 45a3f38704444fbe889c93ff3624688b04927bed [file] [log] [blame] [edit]
#![deny(rust_2018_idioms)]
use std::{
borrow::{Borrow, Cow},
collections::HashSet,
fmt::{self, Write},
};
use pulldown_cmark::{Alignment as TableAlignment, Event, HeadingLevel, LinkType, MetadataBlockKind, Tag, TagEnd};
/// Similar to [Pulldown-Cmark-Alignment][Alignment], but with required
/// traits for comparison to allow testing.
#[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,
}
}
}
/// The state of the [`cmark_resume()`] and [`cmark_resume_with_options()`] functions.
/// This does not only allow introspection, but enables the user
/// to halt the serialization at any time, and resume it later.
#[derive(Clone, Default, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct State<'a> {
/// The amount of newlines to insert after `Event::Start(...)`
pub newlines_before_start: usize,
/// The lists and their types for which we have seen a `Event::Start(List(...))` tag
pub list_stack: Vec<Option<u64>>,
/// The computed padding and prefix to print after each newline.
/// This changes with the level of `BlockQuote` and `List` events.
pub padding: Vec<Cow<'a, str>>,
/// Keeps the current table alignments, if we are currently serializing a table.
pub table_alignments: Vec<Alignment>,
/// Keeps the current table headers, if we are currently serializing a table.
pub table_headers: Vec<String>,
/// The last seen text when serializing a header
pub text_for_header: Option<String>,
/// Is set while we are handling text in a code block
pub is_in_code_block: bool,
/// True if the last event was text and the text does not have trailing newline. Used to inject additional newlines before code block end fence.
pub last_was_text_without_trailing_newline: bool,
/// Currently open links
pub link_stack: Vec<LinkCategory<'a>>,
/// Currently open images
pub image_stack: Vec<ImageLink<'a>>,
/// Keeps track of the last seen heading's id, classes, and attributes
pub current_heading: Option<Heading<'a>>,
/// Keeps track of the last seen shortcut/link
pub current_shortcut_text: Option<String>,
/// A list of shortcuts seen so far for later emission
pub shortcuts: Vec<(String, String, String)>,
}
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum LinkCategory<'a> {
AngleBracketed,
Shortcut { uri: Cow<'a, str>, title: Cow<'a, str> },
Other { uri: Cow<'a, str>, title: Cow<'a, str> },
}
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct ImageLink<'a> {
uri: Cow<'a, str>,
title: Cow<'a, str>,
}
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct Heading<'a> {
id: Option<Cow<'a, str>>,
classes: Vec<Cow<'a, str>>,
attributes: Vec<(Cow<'a, str>, Option<Cow<'a, str>>)>,
}
/// Thea mount of code-block tokens one needs to produce a valid fenced code-block.
pub const DEFAULT_CODE_BLOCK_TOKEN_COUNT: usize = 3;
/// Configuration for the [`cmark_with_options()`] and [`cmark_resume_with_options()`] functions.
/// The defaults should provide decent spacing and most importantly, will
/// provide a faithful rendering of your markdown document particularly when
/// rendering it to HTML.
///
/// It's best used with its `Options::default()` implementation.
#[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_htmlblock: 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,
/// The amount of newlines placed after TOML or YAML metadata blocks at the beginning of a document.
pub newlines_after_metadata: usize,
/// Token count for fenced code block. An appropriate value of this field can be decided by
/// [`calculate_code_block_token_count()`].
/// Note that the default value is `4` which allows for one level of nested code-blocks,
/// which is typically a safe value for common kinds of markdown documents.
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_htmlblock: 1,
newlines_after_table: 2,
newlines_after_rule: 2,
newlines_after_list: 2,
newlines_after_blockquote: 2,
newlines_after_rest: 1,
newlines_after_metadata: 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> {
// These always need to be escaped, even if reconfigured.
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()
}
}
}
/// Serialize a stream of [pulldown-cmark-Events][Event] into a string-backed buffer.
///
/// 1. **events**
/// * An iterator over [`Events`][Event], for example as returned by the [`Parser`][pulldown_cmark::Parser]
/// 1. **formatter**
/// * A format writer, can be a `String`.
/// 1. **state**
/// * The optional initial state of the serialization.
/// 1. **options**
/// * Customize the appearance of the serialization. All otherwise magic values are contained
/// here.
///
/// *Returns* the [`State`] of the serialization on success. You can use it as initial state in the
/// next call if you are halting event serialization.
/// *Errors* are only happening if the underlying buffer fails, which is unlikely.
pub fn cmark_resume_with_options<'a, I, E, F>(
events: I,
mut formatter: F,
state: Option<State<'a>>,
options: Options<'_>,
) -> Result<State<'a>, 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();
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(text, '`') + 1);
let space = match text.as_bytes() {
&[b'`', ..] | &[.., b'`'] => " ", // Space needed to separate backtick.
&[b' ', .., b' '] => " ", // Space needed to escape inner space.
_ => "", // No space needed.
};
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 {
link_type,
dest_url,
title,
id: _,
} => {
state.link_stack.push(match link_type {
LinkType::Autolink | LinkType::Email => {
formatter.write_char('<')?;
LinkCategory::AngleBracketed
}
LinkType::Shortcut => {
state.current_shortcut_text = Some(String::new());
formatter.write_char('[')?;
LinkCategory::Shortcut {
uri: dest_url.clone().into(),
title: title.clone().into(),
}
}
_ => {
formatter.write_char('[')?;
LinkCategory::Other {
uri: dest_url.clone().into(),
title: title.clone().into(),
}
}
});
Ok(())
}
Image {
link_type: _,
dest_url,
title,
id: _,
} => {
state.image_stack.push(ImageLink {
uri: dest_url.clone().into(),
title: title.clone().into(),
});
formatter.write_str("![")
}
Emphasis => formatter.write_char(options.emphasis_token),
Strong => formatter.write_str(options.strong_token),
FootnoteDefinition(ref name) => {
state.padding.push(" ".into());
write!(formatter, "[^{}]: ", name)
}
Paragraph => Ok(()),
Heading {
level,
id,
classes,
attrs,
} => {
assert_eq!(state.current_heading, None);
state.current_heading = Some(self::Heading {
id: id.as_ref().map(|id| id.clone().into()),
classes: classes.iter().map(|class| class.clone().into()).collect(),
attributes: attrs
.iter()
.map(|(k, v)| (k.clone().into(), v.as_ref().map(|val| val.clone().into())))
.collect(),
});
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 we consumed some newlines, we know that we can just write out the next
// level in our blockquote. This should work regardless if we have other
// padding or if we're in a list
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))
}
HtmlBlock => Ok(()),
MetadataBlock(MetadataBlockKind::YamlStyle) => formatter.write_str("---\n"),
MetadataBlock(MetadataBlockKind::PlusesStyle) => formatter.write_str("+++\n"),
List(_) => Ok(()),
Strikethrough => formatter.write_str("~~"),
}
}
End(ref tag) => match tag {
TagEnd::Link => match state.link_stack.pop().unwrap() {
LinkCategory::AngleBracketed => formatter.write_char('>'),
LinkCategory::Shortcut { uri, 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(']')
}
LinkCategory::Other { uri, title } => close_link(&uri, &title, &mut formatter, LinkType::Inline),
},
TagEnd::Image => {
let ImageLink { uri, title } = state.image_stack.pop().unwrap();
close_link(uri.as_ref(), title.as_ref(), &mut formatter, LinkType::Inline)
}
TagEnd::Emphasis => formatter.write_char(options.emphasis_token),
TagEnd::Strong => formatter.write_str(options.strong_token),
TagEnd::Heading(_) => {
let self::Heading {
id,
classes,
attributes,
} = state.current_heading.take().unwrap();
let emit_braces = id.is_some() || !classes.is_empty() || !attributes.is_empty();
if emit_braces {
formatter.write_str(" {")?;
}
if let Some(id_str) = id {
formatter.write_char(' ')?;
formatter.write_char('#')?;
formatter.write_str(&id_str)?;
}
for class in classes.iter() {
formatter.write_char(' ')?;
formatter.write_char('.')?;
formatter.write_str(class)?;
}
for (key, val) in attributes.iter() {
formatter.write_char(' ')?;
formatter.write_str(key)?;
if let Some(val) = val {
formatter.write_char('=')?;
formatter.write_str(val)?;
}
}
if emit_braces {
formatter.write_char(' ')?;
formatter.write_char('}')?;
}
if state.newlines_before_start < options.newlines_after_headline {
state.newlines_before_start = options.newlines_after_headline;
}
Ok(())
}
TagEnd::Paragraph => {
if state.newlines_before_start < options.newlines_after_paragraph {
state.newlines_before_start = options.newlines_after_paragraph;
}
Ok(())
}
TagEnd::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(())
}
TagEnd::HtmlBlock => {
if state.newlines_before_start < options.newlines_after_htmlblock {
state.newlines_before_start = options.newlines_after_htmlblock;
}
Ok(())
}
TagEnd::MetadataBlock(MetadataBlockKind::PlusesStyle) => {
if state.newlines_before_start < options.newlines_after_metadata {
state.newlines_before_start = options.newlines_after_metadata;
}
formatter.write_str("+++\n")
}
TagEnd::MetadataBlock(MetadataBlockKind::YamlStyle) => {
if state.newlines_before_start < options.newlines_after_metadata {
state.newlines_before_start = options.newlines_after_metadata;
}
formatter.write_str("---\n")
}
TagEnd::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(())
}
TagEnd::TableCell => {
state.table_headers.push(
state
.text_for_header
.take()
.filter(|s| !s.is_empty())
.unwrap_or_else(|| " ".into()),
);
Ok(())
}
ref t @ TagEnd::TableRow | ref t @ TagEnd::TableHead => {
if state.newlines_before_start < options.newlines_after_rest {
state.newlines_before_start = options.newlines_after_rest;
}
formatter.write_char('|')?;
if let TagEnd::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('|')?;
// NOTE: For perfect counting, count grapheme clusters.
// The reason this is not done is to avoid the dependency.
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(())
}
TagEnd::Item => {
state.padding.pop();
if state.newlines_before_start < options.newlines_after_rest {
state.newlines_before_start = options.newlines_after_rest;
}
Ok(())
}
TagEnd::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(())
}
TagEnd::BlockQuote => {
state.padding.pop();
if state.newlines_before_start < options.newlines_after_blockquote {
state.newlines_before_start = options.newlines_after_blockquote;
}
Ok(())
}
TagEnd::FootnoteDefinition => {
state.padding.pop();
Ok(())
}
TagEnd::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,
)
}
InlineHtml(ref text) => {
consume_newlines(&mut formatter, &mut state)?;
print_text_without_trailing_newline(text, &mut formatter, &state.padding)
}
Html(ref text) => {
let mut lines = text.split('\n');
if let Some(line) = lines.next() {
formatter.write_str(line)?;
}
for line in lines {
formatter.write_char('\n')?;
padding(&mut formatter, &state.padding)?;
formatter.write_str(line)?;
}
Ok(())
}
FootnoteReference(ref name) => write!(formatter, "[^{}]", name),
TaskListMarker(checked) => {
let check = if checked { "x" } else { " " };
write!(formatter, "[{}] ", check)
}
}?
}
Ok(state)
}
/// As [`cmark_resume_with_options()`], but with default [`Options`].
pub fn cmark_resume<'a, I, E, F>(events: I, formatter: F, state: Option<State<'a>>) -> Result<State<'a>, 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 = EscapeLinkTitle(title))?;
}
if link_type != LinkType::Shortcut {
f.write_char(')')?;
}
Ok(())
}
struct EscapeLinkTitle<'a>(&'a str);
/// Writes a link title with double quotes escaped.
/// See https://spec.commonmark.org/0.30/#link-title for the rules around
/// link titles and the characters they may contain.
impl fmt::Display for EscapeLinkTitle<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
for c in self.0.chars() {
match c {
'"' => f.write_str(r#"\""#)?,
'\\' => f.write_str(r"\\")?,
c => f.write_char(c)?,
}
}
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)
}
}
/// As [`cmark_resume_with_options()`], but with the [`State`] finalized.
pub fn cmark_with_options<'a, I, E, F>(
events: I,
mut formatter: F,
options: Options<'_>,
) -> Result<State<'a>, 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)
}
/// As [`cmark_with_options()`], but with default [`Options`].
pub fn cmark<'a, I, E, F>(events: I, mut formatter: F) -> Result<State<'a>, fmt::Error>
where
I: Iterator<Item = E>,
E: Borrow<Event<'a>>,
F: fmt::Write,
{
cmark_with_options(events, &mut formatter, Default::default())
}
/// Return the `<seen amount of consecutive fenced code-block tokens> + 1` that occur *within* a
/// fenced code-block `events`.
///
/// Use this function to obtain the correct value for `code_block_token_count` field of [`Options`]
/// to assure that the enclosing code-blocks remain functional as such.
///
/// Returns `None` if `events` didn't include any code-block, or the code-block didn't contain
/// a nested block. In that case, the correct amount of fenced code-block tokens is
/// [`DEFAULT_CODE_BLOCK_TOKEN_COUNT`].
///
/// ```rust
/// use pulldown_cmark::Event;
/// use pulldown_cmark_to_cmark::*;
///
/// let events = &[Event::Text("text".into())];
/// let code_block_token_count = calculate_code_block_token_count(events).unwrap_or(DEFAULT_CODE_BLOCK_TOKEN_COUNT);
/// let options = Options {
/// code_block_token_count,
/// ..Default::default()
/// };
/// let mut buf = String::new();
/// cmark_with_options(events.iter(), &mut buf, options);
/// ```
pub fn calculate_code_block_token_count<'a, I, E>(events: I) -> Option<usize>
where
I: IntoIterator<Item = E>,
E: Borrow<Event<'a>>,
{
let mut in_codeblock = false;
let mut max_token_count = 0;
// token_count should be taken over Text events
// because a continuous text may be splitted to some Text events.
let mut token_count = 0;
let mut prev_token_char = None;
for event in events {
match event.borrow() {
Event::Start(Tag::CodeBlock(_)) => {
in_codeblock = true;
}
Event::End(TagEnd::CodeBlock) => {
in_codeblock = false;
prev_token_char = None;
}
Event::Text(x) if in_codeblock => {
for c in x.chars() {
let prev_token = prev_token_char.take();
if c == '`' || c == '~' {
prev_token_char = Some(c);
if Some(c) == prev_token {
token_count += 1;
} else {
max_token_count = max_token_count.max(token_count);
token_count = 1;
}
}
}
}
_ => prev_token_char = None,
}
}
max_token_count = max_token_count.max(token_count);
(max_token_count >= 3).then_some(max_token_count + 1)
}
fn count_consecutive(text: &str, search: char) -> usize {
let mut in_tokens = false;
let mut max_backticks = 0;
let mut cur_tokens = 0;
for ch in text.chars() {
if ch == search {
cur_tokens += 1;
in_tokens = true;
} else if in_tokens {
max_backticks = max_backticks.max(cur_tokens);
cur_tokens = 0;
in_tokens = false;
}
}
max_backticks.max(cur_tokens)
}
#[cfg(test)]
mod count_consecutive {
use super::count_consecutive;
#[test]
fn happens_in_the_entire_string() {
assert_eq!(
count_consecutive("``a```b``", '`'),
3,
"the highest seen consecutive segment of backticks counts"
);
assert_eq!(count_consecutive("```a``b`", '`'), 3, "it can't be downgraded later");
}
}