use crate::substitution_text::Text;
use crate::AttributePart;
use std::borrow::Cow;
pub(crate) fn apply_text(mut text: Text, left: bool, right: bool) -> Text {
for stage in 0..13 {
text = replace_stage(text, stage, left, right, true);
}
text
}
pub(crate) fn apply_parts(parts: Vec<AttributePart>) -> Vec<AttributePart> {
let mut text = Text::from_parts(&parts);
for stage in 0..13 {
text = replace_stage(text, stage, true, true, false);
}
text.into_parts()
}
struct Match<'a> {
end: usize,
escape: Option<usize>,
lexical: Cow<'a, str>,
decoded: Option<&'a str>,
}
fn replace_stage(text: Text, stage: usize, left: bool, right: bool, decode: bool) -> Text {
const TRIGGERS: &[u8; 13] = b"(((--.`'-=&&&";
let trigger = char::from(TRIGGERS[stage]);
let src = &text.text;
if !src.contains(trigger) {
return text;
}
let mut output = Text::default();
let mut copied = 0;
let mut next = 0;
let mut search = 0;
while let Some(found) = src[search..].find(trigger) {
let t = search + found;
for at in [t.saturating_sub(1), t] {
if at < next || !src.is_char_boundary(at) {
continue;
}
let Some(m) = match_at(src, at, stage, left, right) else {
next = at + 1;
continue;
};
text.copy(copied..at, &mut output);
if let Some(escape) = m.escape {
text.copy(at..escape, &mut output);
text.copy(escape + 1..m.end, &mut output);
} else {
text.replacement(
at..m.end,
&m.lexical,
if decode { m.decoded } else { None },
&mut output,
);
}
copied = m.end;
next = m.end;
}
search = next.max(t + 1);
}
if copied == 0 {
return text;
}
text.copy(copied..text.text.len(), &mut output);
output
}
fn match_at(src: &str, at: usize, stage: usize, left: bool, right: bool) -> Option<Match<'_>> {
let bytes = src.as_bytes();
let escaped = bytes[at] == b'\\';
let start = at + usize::from(escaped);
let rest = &src[start..];
let fixed = |pattern: &str, lexical, decoded| {
rest.starts_with(pattern).then_some(Match {
end: start + pattern.len(),
escape: escaped.then_some(at),
lexical: Cow::Borrowed(lexical),
decoded: Some(decoded),
})
};
match stage {
0 => fixed("(C)", "©", "©"),
1 => fixed("(R)", "®", "®"),
2 => fixed("(TM)", "™", "™"),
3 => {
let dash = if matches!(bytes[at], b' ' | b'\n' | b'\\') {
at + 1
} else if at == 0 && left {
at
} else {
return None;
};
if !src[dash..].starts_with("--") {
return None;
}
let end = dash + 2;
let end = match bytes.get(end) {
Some(b' ' | b'\n') => end + 1,
None if right => end,
_ => return None,
};
Some(Match {
end,
escape: escaped.then_some(at),
lexical: Cow::Borrowed(" — "),
decoded: Some("\u{2009}—\u{2009}"),
})
}
4 => {
if !src[..at].chars().next_back().is_some_and(is_word)
|| !rest.starts_with("--")
|| !rest[2..].chars().next().is_some_and(is_word)
{
return None;
}
fixed("--", "—​", "—\u{200b}")
}
5 => fixed("...", "…​", "…\u{200b}"),
6 => fixed("`'", "’", "’"),
7 => {
if !src[..at]
.chars()
.next_back()
.is_some_and(crate::unicode_word::is_alphanumeric)
|| !rest.starts_with("'")
|| !rest[1..].chars().next().is_some_and(char::is_alphabetic)
{
return None;
}
fixed("'", "’", "’")
}
8 => fixed("->", "→", "→"),
9 => fixed("=>", "⇒", "⇒"),
10 => fixed("<-", "←", "←"),
11 => fixed("<=", "⇐", "⇐"),
12 => {
let end = crate::entity_references::entity_end(bytes, start)?;
Some(Match {
end,
escape: escaped.then_some(at),
lexical: Cow::Owned(format!("&{}", &src[start + 5..end])),
decoded: None,
})
}
_ => unreachable!(),
}
}
fn is_word(ch: char) -> bool {
crate::unicode_word::is_alphanumeric(ch) || crate::unicode_word::is_mark_or_connector(ch)
}
#[cfg(test)]
fn apply(src: &str) -> String {
let text = Text::from_parts(&[AttributePart::Text(src.into())]);
crate::attributes::parts_text(&apply_text(text, true, true).into_parts())
}
#[cfg(test)]
mod tests {
use super::*;
fn r(s: &str) -> String {
apply(s)
}
#[test]
fn plain_text_is_unchanged() {
assert_eq!(apply("nothing to do here"), "nothing to do here");
}
#[test]
fn registered_symbols() {
assert_eq!(r("(C) (R) (TM)"), "\u{a9} \u{ae} \u{2122}");
assert_eq!(r("(C)(R)(TM)"), "\u{a9}\u{ae}\u{2122}");
}
#[test]
fn spaced_em_dash_consumes_one_blank_each_side() {
assert_eq!(r("a -- b"), "a\u{2009}\u{2014}\u{2009}b");
assert_eq!(r("a -- b"), "a \u{2009}\u{2014}\u{2009} b");
}
#[test]
fn spaced_em_dash_at_line_boundaries() {
assert_eq!(r("-- start"), "\u{2009}\u{2014}\u{2009}start");
assert_eq!(r("end --"), "end\u{2009}\u{2014}\u{2009}");
}
#[test]
fn word_em_dash_keeps_words_and_adds_zwsp() {
assert_eq!(r("word--word"), "word\u{2014}\u{200b}word");
}
#[test]
fn dash_without_a_full_boundary_stays_literal() {
assert_eq!(r("a-- b"), "a-- b");
assert_eq!(r("a --b"), "a --b");
assert_eq!(r("three --- dashes"), "three --- dashes");
}
#[test]
fn ellipsis_takes_three_dots_only() {
assert_eq!(r("Wait..."), "Wait\u{2026}\u{200b}");
assert_eq!(r("dots.... four"), "dots\u{2026}\u{200b}. four");
assert_eq!(r("two.. dots"), "two.. dots");
}
#[test]
fn apostrophe_only_between_word_chars() {
assert_eq!(r("it's"), "it\u{2019}s");
assert_eq!(r("rock 'n' roll"), "rock 'n' roll");
assert_eq!(r("kids' toys"), "kids' toys");
}
#[test]
fn arrows() {
assert_eq!(r("-> => <- <="), "\u{2192} \u{21d2} \u{2190} \u{21d0}");
assert_eq!(r("a->b"), "a\u{2192}b");
}
#[test]
fn right_arrow_wins_over_left_in_ambiguous_runs() {
assert_eq!(r("<=>"), "<\u{21d2}");
assert_eq!(r("<->"), "<\u{2192}");
}
#[test]
fn backslash_escapes_each_construct() {
assert_eq!(r("\\(C)"), "(C)");
assert_eq!(r("\\--"), "--");
assert_eq!(r("\\..."), "...");
assert_eq!(r("\\->"), "->");
assert_eq!(r("it\\'s"), "it's");
assert_eq!(r("a\\b"), "a\\b");
}
#[test]
fn replacements_compose_in_reference_order() {
assert_eq!(
r("(C) 2026 -- it's a test..."),
"\u{a9} 2026\u{2009}\u{2014}\u{2009}it\u{2019}s a test\u{2026}\u{200b}"
);
}
fn replace_stage_scan(text: Text, stage: usize, left: bool, right: bool) -> Text {
let mut output = Text::default();
let mut copied = 0;
let mut offset = 0;
while offset < text.text.len() {
if let Some(m) = match_at(&text.text, offset, stage, left, right) {
text.copy(copied..offset, &mut output);
if let Some(escape) = m.escape {
text.copy(offset..escape, &mut output);
text.copy(escape + 1..m.end, &mut output);
} else {
text.replacement(offset..m.end, &m.lexical, m.decoded, &mut output);
}
copied = m.end;
offset = m.end;
} else {
offset += text.text[offset..].chars().next().unwrap().len_utf8();
}
}
if copied == 0 {
return text;
}
text.copy(copied..text.text.len(), &mut output);
output
}
type Snapshot = (
String,
Vec<(std::ops::Range<usize>, String, Option<String>, crate::Span)>,
Vec<AttributePart>,
);
fn snapshot(text: Text) -> Snapshot {
let fragments = text
.fragments
.iter()
.map(|(r, f)| (r.clone(), f.text.clone(), f.decoded.clone(), f.span))
.collect();
(text.text.clone(), fragments, text.into_parts())
}
#[test]
fn trigger_jumps_match_a_scan_of_every_offset() {
const TOKENS: &[&str] = &[
"a", "\u{416}", "\u{e9}", " ", "\n", "\\", "-", "--", "(C)", "(R)", "(TM)", ".", "...",
"'", "`", "=", "<", ">", "&", "©",
];
let mut inputs = vec![String::new()];
for _ in 0..3 {
let longer: Vec<String> = inputs
.iter()
.flat_map(|s| TOKENS.iter().map(move |t| format!("{s}{t}")))
.collect();
inputs.extend(longer);
}
inputs.sort();
inputs.dedup();
for input in &inputs {
for stage in 0..13 {
for (left, right) in [(true, true), (false, false)] {
let text = || {
let mut text = Text::default();
text.append_source(input, false, 0, crate::Span::new(5, 5 + input.len()));
text
};
let actual = replace_stage(text(), stage, left, right, true);
let expected = replace_stage_scan(text(), stage, left, right);
assert_eq!(
snapshot(actual),
snapshot(expected),
"{input:?} stage {stage}"
);
}
}
}
}
#[test]
fn non_ascii_text_passes_through() {
assert_eq!(r("café -- thé"), "café\u{2009}\u{2014}\u{2009}thé");
}
}