pub(crate) const CONFUSABLE_MAP: &[(char, &str)] = &[
('\u{201C}', "\""), ('\u{201D}', "\""), ('\u{2018}', "'"), ('\u{2019}', "'"), ('\u{2014}', "--"), ('\u{2013}', "-"), ('\u{2026}', "..."), ('\u{00A0}', " "), ];
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ConfusableHit {
pub byte_offset: usize,
pub unicode_char: char,
pub ascii_replacement: &'static str,
pub line_number: usize,
}
fn lookup(c: char) -> Option<&'static str> {
CONFUSABLE_MAP
.iter()
.find(|&&(ch, _)| ch == c)
.map(|&(_, replacement)| replacement)
}
pub(crate) fn has_confusables(s: &str) -> bool {
s.chars().any(|c| lookup(c).is_some())
}
pub(crate) fn normalize_confusables(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match lookup(c) {
Some(replacement) => out.push_str(replacement),
None => out.push(c),
}
}
out
}
pub(crate) fn detect_confusables(s: &str) -> Vec<ConfusableHit> {
let mut hits = Vec::new();
let mut line: usize = 1;
for (byte_offset, c) in s.char_indices() {
if let Some(replacement) = lookup(c) {
hits.push(ConfusableHit {
byte_offset,
unicode_char: c,
ascii_replacement: replacement,
line_number: line,
});
}
if c == '\n' {
line += 1;
}
}
hits
}
pub(crate) fn build_offset_map(s: &str) -> (String, Vec<usize>) {
let mut normalized = String::with_capacity(s.len());
let mut offset_map: Vec<usize> = Vec::with_capacity(s.len() + 1);
for (orig_byte_offset, c) in s.char_indices() {
match lookup(c) {
Some(replacement) => {
for _ in 0..replacement.len() {
offset_map.push(orig_byte_offset);
}
normalized.push_str(replacement);
}
None => {
let char_len = c.len_utf8();
for i in 0..char_len {
offset_map.push(orig_byte_offset + i);
}
normalized.push(c);
}
}
}
offset_map.push(s.len());
debug_assert_eq!(offset_map.len(), normalized.len() + 1);
debug_assert_eq!(*offset_map.last().unwrap(), s.len());
(normalized, offset_map)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn normalize_left_double_quote() {
assert_eq!(normalize_confusables("\u{201C}hello"), "\"hello");
}
#[test]
fn normalize_right_double_quote() {
assert_eq!(normalize_confusables("hello\u{201D}"), "hello\"");
}
#[test]
fn normalize_left_single_quote() {
assert_eq!(normalize_confusables("\u{2018}hi"), "'hi");
}
#[test]
fn normalize_right_single_quote() {
assert_eq!(normalize_confusables("hi\u{2019}"), "hi'");
}
#[test]
fn normalize_em_dash() {
assert_eq!(normalize_confusables("foo\u{2014}bar"), "foo--bar");
}
#[test]
fn normalize_en_dash() {
assert_eq!(normalize_confusables("10\u{2013}20"), "10-20");
}
#[test]
fn normalize_ellipsis() {
assert_eq!(normalize_confusables("wait\u{2026}"), "wait...");
}
#[test]
fn normalize_nbsp() {
assert_eq!(normalize_confusables("hello\u{00A0}world"), "hello world");
}
#[test]
fn normalize_pure_ascii_is_identity() {
let ascii = "The quick brown fox jumps over the lazy dog. 0123456789 !@#$%^&*()";
assert_eq!(normalize_confusables(ascii), ascii);
}
#[test]
fn normalize_empty_string() {
assert_eq!(normalize_confusables(""), "");
}
#[test]
fn normalize_preserves_non_confusable_unicode() {
let s = "hello 🌍 世界";
assert_eq!(normalize_confusables(s), s);
}
#[test]
fn has_confusables_false_for_ascii() {
assert!(!has_confusables("plain ASCII text"));
}
#[test]
fn has_confusables_false_for_non_confusable_unicode() {
assert!(!has_confusables("emoji 🎉 and 日本語"));
}
#[test]
fn has_confusables_true_for_smart_quotes() {
assert!(has_confusables("He said \u{201C}hello\u{201D}"));
}
#[test]
fn has_confusables_true_for_nbsp() {
assert!(has_confusables("a\u{00A0}b"));
}
#[test]
fn has_confusables_true_for_em_dash() {
assert!(has_confusables("a\u{2014}b"));
}
#[test]
fn detect_returns_empty_for_ascii() {
assert!(detect_confusables("plain text").is_empty());
}
#[test]
fn detect_single_smart_quote() {
let hits = detect_confusables("say \u{201C}hi\u{201D}");
assert_eq!(hits.len(), 2);
assert_eq!(hits[0].byte_offset, 4); assert_eq!(hits[0].unicode_char, '\u{201C}');
assert_eq!(hits[0].ascii_replacement, "\"");
assert_eq!(hits[0].line_number, 1);
assert_eq!(hits[1].byte_offset, 9);
assert_eq!(hits[1].unicode_char, '\u{201D}');
assert_eq!(hits[1].ascii_replacement, "\"");
assert_eq!(hits[1].line_number, 1);
}
#[test]
fn detect_confusables_tracks_line_numbers() {
let s = "line one\nline\u{00A0}two\nline \u{201C}three\u{201D}\n";
let hits = detect_confusables(s);
assert_eq!(hits.len(), 3);
assert_eq!(hits[0].line_number, 2); assert_eq!(hits[1].line_number, 3); assert_eq!(hits[2].line_number, 3); }
#[test]
fn detect_consecutive_confusables() {
let s = "\u{2014}\u{2014}";
let hits = detect_confusables(s);
assert_eq!(hits.len(), 2);
assert_eq!(hits[0].byte_offset, 0);
assert_eq!(hits[1].byte_offset, 3); }
#[test]
fn detect_confusable_at_start_and_end() {
let s = "\u{2018}hello\u{2019}";
let hits = detect_confusables(s);
assert_eq!(hits.len(), 2);
assert_eq!(hits[0].byte_offset, 0);
assert_eq!(hits[0].unicode_char, '\u{2018}');
assert_eq!(hits[1].byte_offset, 8);
assert_eq!(hits[1].unicode_char, '\u{2019}');
}
#[test]
fn offset_map_pure_ascii() {
let s = "abc";
let (normalized, map) = build_offset_map(s);
assert_eq!(normalized, "abc");
assert_eq!(map, vec![0, 1, 2, 3]);
}
#[test]
fn offset_map_empty_string() {
let (normalized, map) = build_offset_map("");
assert_eq!(normalized, "");
assert_eq!(map, vec![0]);
}
#[test]
fn offset_map_terminal_sentinel() {
let s = "hello";
let (normalized, map) = build_offset_map(s);
assert_eq!(normalized, "hello");
assert_eq!(map.len(), normalized.len() + 1);
assert_eq!(*map.last().unwrap(), s.len());
}
#[test]
fn offset_map_smart_quotes() {
let s = "\u{201C}hi\u{201D}";
let (normalized, map) = build_offset_map(s);
assert_eq!(normalized, "\"hi\"");
assert_eq!(normalized.len(), 4);
assert_eq!(map.len(), 5);
assert_eq!(map[0], 0);
assert_eq!(map[1], 3);
assert_eq!(map[2], 4);
assert_eq!(map[3], 5);
assert_eq!(map[4], 8);
}
#[test]
fn offset_map_em_dash() {
let s = "a\u{2014}b";
let (normalized, map) = build_offset_map(s);
assert_eq!(normalized, "a--b");
assert_eq!(map.len(), 5);
assert_eq!(map[0], 0); assert_eq!(map[1], 1); assert_eq!(map[2], 1); assert_eq!(map[3], 4); assert_eq!(map[4], 5); }
#[test]
fn offset_map_ellipsis() {
let s = "\u{2026}";
let (normalized, map) = build_offset_map(s);
assert_eq!(normalized, "...");
assert_eq!(map.len(), 4);
assert_eq!(map[0], 0);
assert_eq!(map[1], 0);
assert_eq!(map[2], 0);
assert_eq!(map[3], 3); }
#[test]
fn offset_map_nbsp() {
let s = "a\u{00A0}b";
let (normalized, map) = build_offset_map(s);
assert_eq!(normalized, "a b");
assert_eq!(map.len(), 4);
assert_eq!(map[0], 0); assert_eq!(map[1], 1); assert_eq!(map[2], 3); assert_eq!(map[3], 4); }
#[test]
fn offset_map_non_confusable_multibyte() {
let s = "a🌍b";
let (normalized, map) = build_offset_map(s);
assert_eq!(normalized, s); assert_eq!(map.len(), 7);
assert_eq!(map[0], 0); assert_eq!(map[1], 1); assert_eq!(map[2], 2); assert_eq!(map[3], 3); assert_eq!(map[4], 4); assert_eq!(map[5], 5); assert_eq!(map[6], 6); }
#[test]
fn offset_map_mixed_confusables_and_ascii() {
let s = "He said \u{201C}yes\u{201D} \u{2014} no\u{2026}";
let (normalized, map) = build_offset_map(s);
assert_eq!(normalized, "He said \"yes\" -- no...");
assert_eq!(map.len(), normalized.len() + 1);
assert_eq!(map[0], 0);
assert_eq!(*map.last().unwrap(), s.len());
for window in map.windows(2) {
assert!(
window[0] <= window[1],
"offset_map not monotonic: {} > {}",
window[0],
window[1]
);
}
}
#[test]
fn offset_map_en_dash_single_char_replacement() {
let s = "\u{2013}";
let (normalized, map) = build_offset_map(s);
assert_eq!(normalized, "-");
assert_eq!(map.len(), 2); assert_eq!(map[0], 0); assert_eq!(map[1], 3); }
#[test]
fn normalize_multiple_confusables_one_line() {
let s = "\u{201C}hello\u{201D}\u{2014}world\u{2026}";
assert_eq!(normalize_confusables(s), "\"hello\"--world...");
}
#[test]
fn detect_then_normalize_roundtrip() {
let original = "She said \u{201C}go\u{201D}";
let hits = detect_confusables(original);
assert_eq!(hits.len(), 2);
let normalized = normalize_confusables(original);
assert_eq!(normalized, "She said \"go\"");
assert!(!has_confusables(&normalized));
}
#[test]
fn build_offset_map_agrees_with_normalize() {
let s = "a\u{201C}b\u{2014}c\u{00A0}d";
let (from_map, _) = build_offset_map(s);
let from_normalize = normalize_confusables(s);
assert_eq!(from_map, from_normalize);
}
fn remap_first_match<'a>(
original: &'a str,
normalized: &str,
offset_map: &[usize],
pattern: &str,
) -> &'a str {
let norm_start = normalized
.find(pattern)
.expect("pattern not found in normalized text");
let norm_end = norm_start + pattern.len();
let orig_start = offset_map[norm_start];
let orig_end = offset_map[norm_end];
&original[orig_start..orig_end]
}
#[test]
fn remap_smart_quotes_roundtrip() {
let original = "She said \u{201C}stream through\u{201D} clearly";
let (normalized, map) = build_offset_map(original);
let pattern = "\"stream through\"";
let orig_slice = remap_first_match(original, &normalized, &map, pattern);
assert_eq!(orig_slice, "\u{201C}stream through\u{201D}");
assert_eq!(normalize_confusables(orig_slice), pattern);
}
#[test]
fn remap_mixed_confusables_roundtrip() {
let original = "use \u{201C}flag\u{201D}\u{00A0}\u{2014}\u{00A0}see docs";
let (normalized, map) = build_offset_map(original);
let pattern = "\"flag\" -- see";
let orig_slice = remap_first_match(original, &normalized, &map, pattern);
assert_eq!(
orig_slice,
"\u{201C}flag\u{201D}\u{00A0}\u{2014}\u{00A0}see"
);
assert_eq!(normalize_confusables(orig_slice), pattern);
}
#[test]
fn remap_match_at_end_of_string() {
let original = "wait\u{2026}";
let (normalized, map) = build_offset_map(original);
let pattern = "wait...";
let orig_slice = remap_first_match(original, &normalized, &map, pattern);
assert_eq!(orig_slice, original);
assert_eq!(normalize_confusables(orig_slice), pattern);
}
}