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