use std::ops::Range;
pub fn emphasis_span(
text: &str,
before: Option<char>,
after: Option<char>,
) -> Option<Range<usize>> {
let mut start = text.len() - text.trim_start().len();
let mut end = text.trim_end().len();
if start >= end {
return None;
}
let touches_a_word = |c: Option<char>| c.is_some_and(|c| !is_flanking_neutral(c));
if start == 0 && touches_a_word(before) {
start = units(text, start, end)
.find(|&(i, j)| !unit_is_neutral(&text[i..j]))
.map_or(end, |(i, _)| i);
}
if end == text.len() && touches_a_word(after) {
end = units(text, start, end)
.filter(|&(i, j)| !unit_is_neutral(&text[i..j]))
.last()
.map_or(start, |(_, j)| j);
}
(start < end).then_some(start..end)
}
pub fn image(alt: &str, destination: &str, in_table_cell: bool) -> String {
let mut out = String::with_capacity(alt.len() + destination.len() + 5);
out.push_str("![");
for (n, word) in alt.split_whitespace().enumerate() {
if n > 0 {
out.push(' ');
}
for (i, c) in word.char_indices() {
match c {
'\\' | '[' | ']' | '`' | '<' => {
out.push('\\');
out.push(c);
}
'&' if starts_character_reference(&word[i + 1..]) => out.push_str("\\&"),
'|' if in_table_cell => out.push_str("\\|"),
_ => out.push(c),
}
}
}
out.push_str("](");
out.push_str(&link_destination(destination, in_table_cell));
out.push(')');
out
}
pub fn code_span(text: &str, in_table_cell: bool) -> String {
if text.is_empty() {
return String::new();
}
let text = text.replace("\r\n", " ").replace(['\r', '\n'], " ");
let longest_run = text.split(|c| c != '`').map(str::len).max().unwrap_or(0);
let fence = "`".repeat(longest_run + 1);
let pad = text.starts_with('`')
|| text.ends_with('`')
|| (text.starts_with(' ')
&& text.ends_with(' ')
&& !text.trim_start_matches(' ').is_empty());
let pad = if pad { " " } else { "" };
let body = if in_table_cell {
text.replace('|', "\\|")
} else {
text
};
format!("{fence}{pad}{body}{pad}{fence}")
}
pub fn link(label: &str, destination: &str, title: Option<&str>, in_table_cell: bool) -> String {
let mut out = String::with_capacity(label.len() + destination.len() + 4);
out.push('[');
push_link_label(&mut out, label, in_table_cell);
out.push_str("](");
out.push_str(&link_destination(destination, in_table_cell));
if let Some(title) = title.filter(|t| !t.is_empty()) {
out.push_str(" \"");
let mut chars = title.char_indices().peekable();
while let Some((i, c)) = chars.next() {
match c {
'\\' if chars.peek().is_none_or(|&(_, n)| n.is_ascii_punctuation()) => {
out.push_str("\\\\")
}
'"' => out.push_str("\\\""),
'&' if starts_character_reference(&title[i + 1..]) => out.push_str("\\&"),
'|' if in_table_cell => out.push_str("\\|"),
'\n' | '\r' => out.push(' '),
_ => out.push(c),
}
}
out.push('"');
}
out.push(')');
out
}
fn push_link_label(out: &mut String, label: &str, in_table_cell: bool) {
let chars: Vec<char> = label.chars().collect();
let mut i = 0;
while i < chars.len() {
let c = chars[i];
match c {
'\\' => match chars.get(i + 1) {
Some(&n) if n.is_ascii_punctuation() => {
out.push('\\');
out.push(n);
i += 2;
continue;
}
None => out.push_str("\\\\"),
Some(_) => out.push('\\'),
},
'`' => {
let run = chars[i..].iter().take_while(|&&b| b == '`').count();
if let Some(close) = closing_backticks(&chars, i + run, run) {
for (k, &s) in chars[i..close + run].iter().enumerate() {
match s {
'\n' | '\r' => out.push(' '),
'|' if in_table_cell && (k == 0 || chars[i + k - 1] != '\\') => {
out.push_str("\\|")
}
_ => out.push(s),
}
}
i = close + run;
} else {
out.extend(std::iter::repeat_n('`', run));
i += run;
}
continue;
}
'[' | ']' => {
out.push('\\');
out.push(c);
}
'|' if in_table_cell => out.push_str("\\|"),
'\n' | '\r' => out.push(' '),
_ => out.push(c),
}
i += 1;
}
}
fn closing_backticks(chars: &[char], from: usize, len: usize) -> Option<usize> {
let mut j = from;
while j < chars.len() {
if chars[j] == '`' {
let run = chars[j..].iter().take_while(|&&b| b == '`').count();
if run == len {
return Some(j);
}
j += run;
} else {
j += 1;
}
}
None
}
pub fn link_destination(url: &str, in_table_cell: bool) -> String {
let bare = !url.is_empty()
&& !url
.chars()
.any(|c| c == ' ' || c == '<' || c == '>' || c.is_ascii_control())
&& parentheses_balance(url);
let mut out = String::with_capacity(url.len() + 2);
if !bare {
out.push('<');
}
let mut chars = url.char_indices().peekable();
while let Some((i, c)) = chars.next() {
match c {
'\\' if chars
.peek()
.is_none_or(|&(_, n)| n.is_ascii_punctuation() || n == '\n' || n == '\r') =>
{
out.push_str("\\\\")
}
'&' if starts_character_reference(&url[i + 1..]) => out.push_str("\\&"),
'<' | '>' => {
out.push('\\');
out.push(c);
}
'|' if in_table_cell => out.push_str("\\|"),
'\n' => out.push_str("%0A"),
'\r' => out.push_str("%0D"),
_ => out.push(c),
}
}
if !bare {
out.push('>');
}
out
}
pub fn escape_block_starts(text: &str) -> String {
let mut out = String::with_capacity(text.len() + 4);
for (n, line) in text.split('\n').enumerate() {
if n > 0 {
out.push('\n');
}
let indent = line.len() - line.trim_start_matches(' ').len();
match block_marker_at(&line[indent.min(line.len())..]).filter(|_| indent <= 3) {
Some(at) => {
let at = indent + at;
out.push_str(&line[..at]);
out.push('\\');
out.push_str(&line[at..]);
}
None => out.push_str(line),
}
}
out
}
pub fn escape_html_syntax(text: &str) -> String {
let mut out = String::with_capacity(text.len() + 2);
for (i, c) in text.char_indices() {
let rest = &text[i + c.len_utf8()..];
let opens = match c {
'<' => rest
.chars()
.next()
.is_none_or(|n| n.is_ascii_alphabetic() || matches!(n, '/' | '!' | '?')),
'&' => starts_character_reference(rest),
_ => false,
};
if opens {
out.push('\\');
}
out.push(c);
}
out
}
pub fn emphasis_can_pair(text: &str, delimiter: char) -> bool {
let chars: Vec<char> = text.chars().collect();
let mut opener_seen = false;
let mut i = 0;
while i < chars.len() {
if chars[i] != delimiter {
i += 1;
continue;
}
let start = i;
while i < chars.len() && chars[i] == delimiter {
i += 1;
}
let before = start.checked_sub(1).map(|b| chars[b]);
let after = chars.get(i).copied();
let space = |c: Option<char>| c.is_none_or(char::is_whitespace);
let punct =
|c: Option<char>| c.is_some_and(|c| is_flanking_neutral(c) && !c.is_whitespace());
let left = !space(after) && (!punct(after) || space(before) || punct(before));
let right = !space(before) && (!punct(before) || space(after) || punct(after));
let (opens, closes) = if delimiter == '_' {
(
left && (!right || punct(before)),
right && (!left || punct(after)),
)
} else {
(left, right)
};
if closes && opener_seen {
return true;
}
opener_seen |= opens;
}
false
}
fn block_marker_at(line: &str) -> Option<usize> {
let bytes = line.as_bytes();
let first = *bytes.first()?;
let ends_marker = |i: usize| matches!(bytes.get(i), None | Some(b' ' | b'\t' | b'\r'));
let content = line.trim_end_matches([' ', '\t', '\r']);
let only = |c: u8, spaced: bool| {
content
.bytes()
.all(|b| b == c || (spaced && (b == b' ' || b == b'\t')))
};
match first {
b'#' => {
let hashes = bytes.iter().take_while(|&&b| b == b'#').count();
(hashes <= 6 && ends_marker(hashes)).then_some(0)
}
b'>' => Some(0),
b'-' | b'+' | b'*' if ends_marker(1) => Some(0),
b'-' if only(b'-', true) => Some(0),
b'*' | b'_'
if only(first, true) && content.bytes().filter(|&b| b == first).count() >= 3 =>
{
Some(0)
}
b'=' if only(b'=', false) => Some(0),
b'`' | b'~' if bytes.iter().take_while(|&&b| b == first).count() >= 3 => Some(0),
b'0'..=b'9' => {
let digits = bytes.iter().take_while(|b| b.is_ascii_digit()).count();
(digits <= 9
&& matches!(bytes.get(digits), Some(b'.' | b')'))
&& ends_marker(digits + 1))
.then_some(digits)
}
_ => None,
}
}
fn starts_character_reference(rest: &str) -> bool {
let Some(end) = rest.find(';') else {
return false;
};
let body = &rest[..end];
if let Some(number) = body.strip_prefix('#') {
return match number.strip_prefix(['x', 'X']) {
Some(hex) => (1..=6).contains(&hex.len()) && hex.chars().all(|c| c.is_ascii_hexdigit()),
None => (1..=7).contains(&number.len()) && number.chars().all(|c| c.is_ascii_digit()),
};
}
body.starts_with(|c: char| c.is_ascii_alphabetic())
&& (2..=32).contains(&body.len())
&& body.chars().all(|c| c.is_ascii_alphanumeric())
}
fn parentheses_balance(url: &str) -> bool {
let mut depth = 0usize;
for c in url.chars() {
match c {
'(' => depth += 1,
')' => match depth.checked_sub(1) {
Some(d) => depth = d,
None => return false,
},
_ => {}
}
}
depth == 0
}
fn is_flanking_neutral(c: char) -> bool {
c.is_whitespace() || !(c.is_alphanumeric() || c.is_control())
}
fn unit_is_neutral(unit: &str) -> bool {
unit.chars().last().is_some_and(is_flanking_neutral)
}
fn units(text: &str, start: usize, end: usize) -> impl Iterator<Item = (usize, usize)> + '_ {
let mut chars = text[start..end].char_indices().peekable();
std::iter::from_fn(move || {
let (i, c) = chars.next()?;
let mut j = i + c.len_utf8();
if c == '\\' {
if let Some(&(k, escaped)) = chars.peek() {
if escaped.is_ascii_punctuation() {
j = k + escaped.len_utf8();
chars.next();
}
}
}
Some((start + i, start + j))
})
}
#[cfg(test)]
mod tests {
use super::*;
fn wrap(text: &str, before: Option<char>, after: Option<char>) -> String {
emphasis_span(text, before, after).map_or(text.to_string(), |r| {
format!(
"{}*{}*{}",
&text[..r.start],
&text[r.clone()],
&text[r.end..]
)
})
}
#[test]
fn whitespace_stays_outside() {
assert_eq!(wrap(" word ", Some('a'), Some('b')), " *word* ");
assert_eq!(emphasis_span(" ", None, None), None);
assert_eq!(emphasis_span("", None, None), None);
}
#[test]
fn leading_punctuation_against_a_word_moves_out_with_its_space() {
assert_eq!(wrap(", s", Some('2'), Some(' ')), ", *s*");
}
#[test]
fn trailing_punctuation_against_a_word_moves_out() {
assert_eq!(wrap("word.", Some(' '), Some('x')), "*word*.");
assert_eq!(wrap("word, ", Some(' '), Some('x')), "*word,* ");
}
#[test]
fn punctuation_between_words_alone_has_nothing_to_wrap() {
assert_eq!(emphasis_span(",", Some('8'), Some('a')), None);
assert_eq!(emphasis_span(", ", Some('8'), None), None);
}
#[test]
fn punctuation_against_space_punctuation_or_an_edge_stays_inside() {
assert_eq!(wrap("i.e.,", Some(' '), Some(' ')), "*i.e.,*");
assert_eq!(wrap("(h)", None, None), "*(h)*");
assert_eq!(wrap("(h)", Some('('), Some(')')), "*(h)*");
assert_eq!(
wrap("Continued pretraining.", None, Some(' ')),
"*Continued pretraining.*"
);
}
#[test]
fn a_word_on_either_side_moves_both_ends() {
assert_eq!(wrap("(h)", Some('Q'), Some('x')), "(*h*)");
}
#[test]
fn an_escape_is_never_split() {
assert_eq!(wrap(r"\*x\_", Some('a'), Some('b')), r"\**x*\_");
}
#[test]
fn a_backslash_before_a_letter_is_not_an_escape() {
assert_eq!(wrap(r"\a", Some('b'), None), r"\*a*");
}
#[test]
fn non_ascii_punctuation_and_letters_count() {
assert_eq!(wrap("“word”", Some('x'), Some('y')), "“*word*”");
assert_eq!(wrap("단어", Some('가'), Some('나')), "*단어*");
}
#[test]
fn block_starts_are_escaped() {
for (line, escaped) in [
("# a", r"\# a"),
("###### a", r"\###### a"),
("#", r"\#"),
("- a", r"\- a"),
("+ a", r"\+ a"),
("* a", r"\* a"),
("-", r"\-"),
("1. a", r"1\. a"),
("1) a", r"1\) a"),
("2024. 10. 14", r"2024\. 10. 14"),
("123456789. a", r"123456789\. a"),
("> a", r"\> a"),
(">a", r"\>a"),
("```", r"\```"),
("~~~ rust", r"\~~~ rust"),
("---", r"\---"),
("* * *", r"\* * *"),
("___", r"\___"),
("===", r"\==="),
(" # a", r" \# a"),
] {
assert_eq!(escape_block_starts(line), escaped, "{line:?}");
}
for line in [
"#a",
"####### a",
"-a",
"*a*",
"1.5 litres",
"1234567890. a",
"a. b",
"__init__",
"**",
"= a",
"``",
" # a",
r"\* a",
"",
] {
assert_eq!(escape_block_starts(line), line, "{line:?}");
}
}
#[test]
fn emphasis_pairs_only_opener_then_closer() {
for (text, delimiter, pairs) in [
("0.039***", '*', false),
("*8 ml", '*', false),
("p < .05*", '*', false),
("a * b * c", '*', false),
("x*y*z", '*', true),
("*bold* text", '*', true),
("**strong**", '*', true),
("a _foo_ b", '_', true),
("snake_case_name", '_', false),
("_init", '_', false),
("(*) and (*)", '*', true),
] {
assert_eq!(emphasis_can_pair(text, delimiter), pairs, "{text:?}");
}
}
#[test]
fn html_syntax_is_escaped_where_it_can_open() {
for (text, escaped) in [
("Vec<String>", r"Vec\<String>"),
("</div>", r"\</div>"),
("<!-- c -->", r"\<!-- c -->"),
("<?php", r"\<?php"),
("<https://example.com>", r"\<https://example.com>"),
("ends with <", r"ends with \<"),
("& & &", r"\& \& \&"),
] {
assert_eq!(escape_html_syntax(text), escaped, "{text:?}");
}
for text in [
"1 < 2",
"a <= b",
"<3",
"<< b",
"R&D",
"a & b",
"¬areference",
"& ;",
] {
assert_eq!(escape_html_syntax(text), text, "{text:?}");
}
}
#[test]
fn every_line_of_the_text_is_checked() {
assert_eq!(
escape_block_starts("first \n# second\nthird"),
"first \n\\# second\nthird"
);
}
}