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
}
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('나')), "*단어*");
}
}