#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum Escape {
Parens,
Wrapping,
Block,
}
pub(super) fn escape_markdown(text: &str, class: Escape) -> String {
let escaped = match class {
Escape::Parens => escape_set(text, &['(', ')']),
Escape::Wrapping => escape_set(text, &['*', '_', '~']),
Escape::Block => escape_block(text),
};
non_breaking_spaces(&escaped)
}
fn escape_set(text: &str, set: &[char]) -> String {
let mut out = String::with_capacity(text.len());
for character in text.chars() {
if set.contains(&character) {
out.push('\\');
}
out.push(character);
}
out
}
fn escape_block(text: &str) -> String {
let Some(start) = first_block_match(text) else {
return text.to_owned();
};
let mut out = String::with_capacity(text.len() + 1);
if let Some(head) = text.get(..start) {
out.push_str(head);
}
out.push('\\');
if let Some(rest) = text.get(start..) {
out.push_str(rest);
}
out
}
fn first_block_match(text: &str) -> Option<usize> {
if text.starts_with('*') || text.starts_with('>') {
return Some(0);
}
let mut indices = text.char_indices().peekable();
while let Some((index, character)) = indices.next() {
if character != '#' {
continue;
}
while let Some((_, '#')) = indices.peek().copied() {
indices.next();
}
if let Some((_, whitespace)) = indices.peek().copied()
&& is_js_whitespace(whitespace)
{
return Some(index);
}
}
None
}
fn is_js_whitespace(character: char) -> bool {
character.is_whitespace() || character == '\u{feff}'
}
fn non_breaking_spaces(text: &str) -> String {
if !text.contains('\u{a0}') {
return text.to_owned();
}
text.replace('\u{a0}', " ")
}
#[cfg(test)]
mod tests {
use super::{Escape, escape_markdown};
#[test]
fn parentheses_are_escaped_everywhere_they_appear() {
assert_eq!(
escape_markdown("https://example.com?q=()", Escape::Parens),
"https://example.com?q=\\(\\)"
);
}
#[test]
fn wrapper_markers_are_escaped_everywhere_they_appear() {
assert_eq!(
escape_markdown("a _sentence_ with *stars* and ~tildes~", Escape::Wrapping),
"a \\_sentence\\_ with \\*stars\\* and \\~tildes\\~"
);
}
#[test]
fn a_leading_star_is_escaped_and_a_later_one_is_not() {
assert_eq!(
escape_markdown("* List item", Escape::Block),
"\\* List item"
);
assert_eq!(escape_markdown("a * b * c", Escape::Block), "a * b * c");
}
#[test]
fn a_leading_angle_bracket_is_escaped_and_a_later_one_is_not() {
assert_eq!(
escape_markdown("> Blockquote", Escape::Block),
"\\> Blockquote"
);
assert_eq!(
escape_markdown("Text > not a blockquote", Escape::Block),
"Text > not a blockquote"
);
}
#[test]
fn a_run_of_hashes_before_whitespace_is_escaped_once() {
assert_eq!(escape_markdown("# Heading", Escape::Block), "\\# Heading");
assert_eq!(
escape_markdown("### Heading", Escape::Block),
"\\### Heading"
);
assert_eq!(
escape_markdown("#Not a heading", Escape::Block),
"#Not a heading"
);
assert_eq!(escape_markdown("a # b # c", Escape::Block), "a \\# b # c");
}
#[test]
fn a_run_of_hashes_backtracks_to_nothing_rather_than_to_a_shorter_run() {
assert_eq!(escape_markdown("##a## b", Escape::Block), "##a\\## b");
}
#[test]
fn non_breaking_spaces_become_entities_in_every_class() {
assert_eq!(escape_markdown("a\u{a0}b", Escape::Block), "a b");
assert_eq!(escape_markdown("a\u{a0}b", Escape::Parens), "a b");
assert_eq!(escape_markdown("a\u{a0}b", Escape::Wrapping), "a b");
}
}