use crate::extract::{Doc, NormOp};
use std::ops::Range;
use unicode_normalization::{is_nfc_quick, IsNormalized, UnicodeNormalization};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SegKind {
Identity,
Mapped,
}
#[derive(Debug, Clone)]
pub struct Seg {
pub norm: Range<usize>,
pub src: Range<usize>,
pub kind: SegKind,
pub flags: u8,
}
#[derive(Debug, Default)]
pub struct NormView {
pub text: String,
pub segs: Vec<Seg>,
pub line_starts: Vec<usize>,
pub zero_width_removed: Vec<usize>,
}
const ENTITIES: &[(&str, &str)] = &[
("—", "\u{2014}"),
("—", "\u{2014}"),
("—", "\u{2014}"),
("–", "\u{2013}"),
("–", "\u{2013}"),
("–", "\u{2013}"),
("…", "\u{2026}"),
(" ", " "),
(" ", " "),
(" ", " "),
(" ", " "),
(" ", " "),
("&", "&"),
("<", "<"),
(">", ">"),
(""", "\""),
(""", "\""),
("'", "'"),
("'", "'"),
(";", ";"),
(";", ";"),
("“", "\u{201C}"),
("”", "\u{201D}"),
("‘", "\u{2018}"),
("’", "\u{2019}"),
("“", "\u{201C}"),
("”", "\u{201D}"),
("‘", "\u{2018}"),
("’", "\u{2019}"),
];
const DEFAULT_IGNORABLE_RANGES: &[(u32, u32)] = &[
(0x00AD, 0x00AD),
(0x034F, 0x034F),
(0x061C, 0x061C),
(0x115F, 0x1160),
(0x17B4, 0x17B5),
(0x180B, 0x180F),
(0x200B, 0x200F),
(0x202A, 0x202E),
(0x2060, 0x206F),
(0x3164, 0x3164),
(0xFE00, 0xFE0F),
(0xFEFF, 0xFEFF),
(0xFFA0, 0xFFA0),
(0xFFF0, 0xFFF8),
(0x1BCA0, 0x1BCA3),
(0x1D173, 0x1D17A),
(0xE0000, 0xE0FFF),
];
fn is_default_ignorable(ch: char) -> bool {
let c = ch as u32;
DEFAULT_IGNORABLE_RANGES
.iter()
.any(|&(lo, hi)| lo <= c && c <= hi)
}
const CONFUSABLE_TO_LATIN: &[(char, &str)] = &[
('а', "a"),
('в', "b"),
('е', "e"),
('к', "k"),
('м', "m"),
('н', "h"),
('о', "o"),
('р', "p"),
('с', "c"),
('т', "t"),
('у', "y"),
('х', "x"),
('і', "i"),
('ј', "j"),
('ѕ', "s"),
('А', "A"),
('В', "B"),
('Е', "E"),
('К', "K"),
('М', "M"),
('Н', "H"),
('О', "O"),
('Р', "P"),
('С', "C"),
('Т', "T"),
('У', "Y"),
('Х', "X"),
('І', "I"),
('Ј', "J"),
('Ѕ', "S"),
('α', "a"),
('ι', "i"),
('κ', "k"),
('ν', "v"),
('ο', "o"),
('ρ', "p"),
('υ', "u"),
('χ', "x"),
('Α', "A"),
('Β', "B"),
('Ε', "E"),
('Ζ', "Z"),
('Η', "H"),
('Ι', "I"),
('Κ', "K"),
('Μ', "M"),
('Ν', "N"),
('Ο', "O"),
('Ρ', "P"),
('Τ', "T"),
('Υ', "Y"),
('Χ', "X"),
];
pub(crate) fn confusable_latin(ch: char) -> Option<&'static str> {
CONFUSABLE_TO_LATIN
.iter()
.find(|(c, _)| *c == ch)
.map(|(_, l)| *l)
}
fn token_fold(token: &str) -> Option<(Vec<Option<String>>, bool)> {
let nfkc: Vec<Option<String>> = token
.chars()
.map(|c| {
if !c.is_ascii() && c.is_alphabetic() {
let f: String = std::iter::once(c).nfkc().collect();
if f != c.to_string() {
return Some(f);
}
}
None
})
.collect();
let witness = token.chars().zip(nfkc.iter()).any(|(c, f)| match f {
Some(f) => !f.is_empty() && f.chars().all(|x| x.is_ascii_alphabetic()),
None => c.is_ascii_alphabetic(),
});
let fully = !witness
&& !token.is_ascii()
&& token.chars().zip(nfkc.iter()).all(|(c, f)| {
c.is_ascii() || matches!(f, Some(x) if x.is_ascii()) || confusable_latin(c).is_some()
});
let confusables_fold = witness || fully;
let repl: Vec<Option<String>> = token
.chars()
.zip(nfkc)
.map(|(c, f)| {
if f.is_some() {
return f;
}
if confusables_fold {
if let Some(l) = confusable_latin(c) {
return Some(l.to_string());
}
}
None
})
.collect();
if repl.iter().all(Option::is_none) {
None
} else {
Some((repl, fully))
}
}
fn fold_replacements(text: &str) -> Vec<(Range<usize>, String, bool)> {
let mut out = Vec::new();
let mut i = 0usize;
while i < text.len() {
let ch = text[i..].chars().next().unwrap_or('\u{FFFD}');
if !ch.is_alphanumeric() {
i += ch.len_utf8();
continue;
}
let tlen: usize = text[i..]
.chars()
.take_while(|c| c.is_alphanumeric())
.map(|c| c.len_utf8())
.sum();
let token = &text[i..i + tlen];
if !token.is_ascii() {
if let Some((repl, fully)) = token_fold(token) {
let mut at = i;
for (c, r) in token.chars().zip(repl) {
let l = c.len_utf8();
if let Some(r) = r {
out.push((at..at + l, r, fully));
}
at += l;
}
}
}
i += tlen;
}
out
}
fn fold_tokens_str(s: &str) -> String {
let repls = fold_replacements(s);
if repls.is_empty() {
return s.to_string();
}
let mut out = String::with_capacity(s.len());
let mut at = 0usize;
for (r, rep, _) in repls {
out.push_str(&s[at..r.start]);
out.push_str(&rep);
at = r.end;
}
out.push_str(&s[at..]);
out
}
pub(crate) fn fold_norm(old: NormView) -> NormView {
let repls = fold_replacements(&old.text);
if repls.is_empty() {
return old;
}
let mut new = NormView {
text: String::with_capacity(old.text.len()),
segs: Vec::with_capacity(old.segs.len() + repls.len()),
line_starts: Vec::with_capacity(old.line_starts.len()),
zero_width_removed: old.zero_width_removed.clone(),
};
let mut checkpoints: Vec<(usize, usize)> = Vec::new();
let mut ri = 0usize;
for seg in &old.segs {
checkpoints.push((seg.norm.start, new.text.len()));
let mut cur = seg.norm.start;
while ri < repls.len() && repls[ri].0.start < seg.norm.end {
let (r, rep, fully) = &repls[ri];
let fold_flags = seg.flags
| if *fully {
crate::extract::F_FULL_FOLD
} else {
0
};
match seg.kind {
SegKind::Identity => {
if cur < r.start {
let ns = new.text.len();
new.text.push_str(&old.text[cur..r.start]);
new.segs.push(Seg {
norm: ns..new.text.len(),
src: seg.src.start + (cur - seg.norm.start)
..seg.src.start + (r.start - seg.norm.start),
kind: SegKind::Identity,
flags: seg.flags,
});
}
let ns = new.text.len();
new.text.push_str(rep);
new.segs.push(Seg {
norm: ns..new.text.len(),
src: seg.src.start + (r.start - seg.norm.start)
..seg.src.start + (r.end - seg.norm.start),
kind: SegKind::Mapped,
flags: fold_flags,
});
cur = r.end;
ri += 1;
}
SegKind::Mapped => {
let ns = new.text.len();
let mut at = seg.norm.start;
let mut any_full = false;
while ri < repls.len() && repls[ri].0.start < seg.norm.end {
let (r2, rep2, fully2) = &repls[ri];
new.text.push_str(&old.text[at..r2.start]);
new.text.push_str(rep2);
any_full |= *fully2;
at = r2.end;
ri += 1;
}
new.text.push_str(&old.text[at..seg.norm.end]);
new.segs.push(Seg {
norm: ns..new.text.len(),
src: seg.src.clone(),
kind: SegKind::Mapped,
flags: seg.flags
| if any_full {
crate::extract::F_FULL_FOLD
} else {
0
},
});
cur = seg.norm.end;
}
}
}
if cur < seg.norm.end || seg.norm.is_empty() {
let ns = new.text.len();
new.text.push_str(&old.text[cur..seg.norm.end]);
new.segs.push(Seg {
norm: ns..new.text.len(),
src: match seg.kind {
SegKind::Identity => seg.src.start + (cur - seg.norm.start)..seg.src.end,
SegKind::Mapped => seg.src.clone(),
},
kind: seg.kind,
flags: seg.flags,
});
}
}
checkpoints.push((old.text.len(), new.text.len()));
for ls in &old.line_starts {
let (o, n) = checkpoints
.iter()
.rev()
.find(|(o, _)| o <= ls)
.copied()
.unwrap_or((0, 0));
new.line_starts.push(n + (ls - o));
}
new
}
pub(crate) fn entity_at(s: &str, at: usize) -> Option<(usize, &'static str)> {
ENTITIES
.iter()
.find(|(e, _)| s[at..].starts_with(*e))
.map(|(e, r)| (e.len(), *r))
}
const FORMAT_RANGES: &[(u32, u32)] = &[
(0x00AD, 0x00AD),
(0x0600, 0x0605),
(0x061C, 0x061C),
(0x06DD, 0x06DD),
(0x070F, 0x070F),
(0x0890, 0x0891),
(0x08E2, 0x08E2),
(0x180E, 0x180E),
(0x200B, 0x200F),
(0x202A, 0x202E),
(0x2060, 0x2064),
(0x2066, 0x206F),
(0xFEFF, 0xFEFF),
(0xFFF9, 0xFFFB),
(0x110BD, 0x110BD),
(0x110CD, 0x110CD),
(0x13430, 0x1343F),
(0x1BCA0, 0x1BCA3),
(0x1D173, 0x1D17A),
(0xE0001, 0xE0001),
(0xE0020, 0xE007F),
];
fn is_format_char(ch: char) -> bool {
let c = ch as u32;
FORMAT_RANGES.iter().any(|&(lo, hi)| lo <= c && c <= hi)
}
fn is_space_separator(ch: char) -> bool {
matches!(
ch as u32,
0xA0 | 0x1680 | 0x2000..=0x200A | 0x202F | 0x205F | 0x3000
)
}
fn is_ordinary_printable(ch: char) -> bool {
ch == ' '
|| (!ch.is_control()
&& !ch.is_whitespace()
&& !is_format_char(ch)
&& !is_default_ignorable(ch))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum NumRef {
Decode(usize, char),
Suppress(usize),
Overlong(usize),
Literal,
}
pub(crate) fn classify_numeric_ref(s: &str, amp: usize) -> NumRef {
let Some(body) = s[amp..].strip_prefix("&#") else {
return NumRef::Literal;
};
let (digits, radix, prefix, cap) = match body.strip_prefix(['x', 'X']) {
Some(hex) => (hex, 16u32, 3usize, 6usize),
None => (body, 10u32, 2usize, 7usize),
};
let run = digits
.bytes()
.take_while(|b| match radix {
16 => b.is_ascii_hexdigit(),
_ => b.is_ascii_digit(),
})
.count();
if run == 0 || digits.as_bytes().get(run) != Some(&b';') {
return NumRef::Literal;
}
let len = prefix + run + 1;
if run > cap {
return NumRef::Overlong(len);
}
let ch = char::from_u32(u32::from_str_radix(&digits[..run], radix).unwrap_or(u32::MAX))
.unwrap_or('\u{FFFD}');
if is_space_separator(ch) {
return NumRef::Decode(len, ' ');
}
if is_ordinary_printable(ch) {
NumRef::Decode(len, ch)
} else {
NumRef::Suppress(len)
}
}
struct Builder<'a> {
src: &'a str,
out: NormView,
}
impl<'a> Builder<'a> {
fn push_identity(&mut self, src_range: Range<usize>, flags: u8) {
if src_range.start >= src_range.end {
return;
}
let norm_start = self.out.text.len();
self.out.text.push_str(&self.src[src_range.clone()]);
if let Some(last) = self.out.segs.last_mut() {
if last.kind == SegKind::Identity
&& last.src.end == src_range.start
&& last.norm.end == norm_start
&& last.flags == flags
{
last.src.end = src_range.end;
last.norm.end = self.out.text.len();
return;
}
}
self.out.segs.push(Seg {
norm: norm_start..self.out.text.len(),
src: src_range,
kind: SegKind::Identity,
flags,
});
}
fn push_mapped(&mut self, src_range: Range<usize>, replacement: &str, flags: u8) {
let norm_start = self.out.text.len();
self.out.text.push_str(replacement);
self.out.segs.push(Seg {
norm: norm_start..self.out.text.len(),
src: src_range,
kind: SegKind::Mapped,
flags,
});
}
fn push_text(&mut self, src_range: Range<usize>, flags: u8) {
let slice = &self.src[src_range.clone()];
let base = src_range.start;
let bytes = slice.as_bytes();
let mut run_start = 0usize;
let mut i = 0usize;
while i < slice.len() {
let b = bytes[i];
if b == b'&' {
if let Some((elen, rep)) = entity_at(slice, i) {
self.push_identity(base + run_start..base + i, flags);
self.push_mapped(base + i..base + i + elen, rep, flags);
i += elen;
run_start = i;
continue;
}
match classify_numeric_ref(slice, i) {
NumRef::Decode(len, ch) => {
self.push_identity(base + run_start..base + i, flags);
let normalized: String = std::iter::once(ch).nfc().collect();
self.push_mapped(base + i..base + i + len, &normalized, flags);
i += len;
run_start = i;
continue;
}
NumRef::Suppress(len) | NumRef::Overlong(len) => {
self.push_identity(base + run_start..base + i, flags);
self.push_mapped(base + i..base + i + len, "\u{FFFD}", flags);
i += len;
run_start = i;
continue;
}
NumRef::Literal => {
i += 1;
continue;
}
}
}
if b == b'\\' && i + 1 < slice.len() && bytes[i + 1].is_ascii_punctuation() {
self.push_identity(base + run_start..base + i, flags);
let c = bytes[i + 1] as char;
self.push_mapped(base + i..base + i + 2, &c.to_string(), flags);
i += 2;
run_start = i;
continue;
}
if b < 0x80 {
i += 1;
continue;
}
let ch = slice[i..].chars().next().unwrap_or('\u{FFFD}');
let ch_len = ch.len_utf8();
if is_default_ignorable(ch) {
self.push_identity(base + run_start..base + i, flags);
self.push_mapped(base + i..base + i + ch_len, "", flags);
self.out.zero_width_removed.push(base + i);
i += ch_len;
run_start = i;
continue;
}
let na_start = i;
let mut j = i;
while j < slice.len() && bytes[j] >= 0x80 {
let c = slice[j..].chars().next().unwrap_or('\u{FFFD}');
if is_default_ignorable(c) {
break;
}
j += c.len_utf8();
}
let run = &slice[na_start..j];
if is_nfc_quick(run.chars()) == IsNormalized::Yes {
i = j;
continue;
}
self.push_identity(base + run_start..base + na_start, flags);
let normalized: String = run.nfc().collect();
self.push_mapped(base + na_start..base + j, &normalized, flags);
i = j;
run_start = i;
}
self.push_identity(base + run_start..base + slice.len(), flags);
}
}
pub fn render_key(s: &str) -> String {
let bytes = s.as_bytes();
let mut decoded = String::with_capacity(s.len());
let mut i = 0usize;
while i < s.len() {
let b = bytes[i];
if b == b'&' {
if let Some((elen, rep)) = entity_at(s, i) {
decoded.push_str(rep);
i += elen;
continue;
}
match classify_numeric_ref(s, i) {
NumRef::Decode(len, ch) => {
decoded.push(ch);
i += len;
continue;
}
NumRef::Suppress(len) | NumRef::Overlong(len) => {
decoded.push('\u{FFFD}');
i += len;
continue;
}
NumRef::Literal => {
decoded.push('&');
i += 1;
continue;
}
}
}
if b == b'\\' && i + 1 < s.len() && bytes[i + 1].is_ascii_punctuation() {
decoded.push(bytes[i + 1] as char);
i += 2;
continue;
}
let ch = s[i..].chars().next().unwrap_or('\u{FFFD}');
if !is_default_ignorable(ch) {
decoded.push(ch);
}
i += ch.len_utf8();
}
let stripped = strip_html(&decoded);
let nfc: String = stripped.nfc().collect();
let nfc = fold_tokens_str(&nfc);
let mut out = String::with_capacity(nfc.len());
let mut prev_ws = false;
for ch in nfc.chars() {
if ch.is_whitespace() {
if !prev_ws && !out.is_empty() {
out.push(' ');
}
prev_ws = true;
} else {
out.extend(ch.to_lowercase());
prev_ws = false;
}
}
while out.ends_with(' ') {
out.pop();
}
out
}
fn strip_html(s: &str) -> String {
let bytes = s.as_bytes();
let n = s.len();
let mut out = String::with_capacity(n);
let mut i = 0usize;
while i < n {
if s[i..].starts_with("<!--") {
match s[i + 4..].find("-->") {
Some(e) => {
i = i + 4 + e + 3;
continue;
}
None => break,
}
}
let b = bytes[i];
let tag_start = b == b'<'
&& matches!(bytes.get(i + 1), Some(&c) if c.is_ascii_alphabetic() || c == b'/' || c == b'!' || c == b'?');
if tag_start {
match s[i..].find('>') {
Some(e) => {
i += e + 1;
continue;
}
None => break,
}
}
let ch = s[i..].chars().next().unwrap_or('\u{FFFD}');
out.push(ch);
i += ch.len_utf8();
}
out
}
pub fn build_norm(src: &str, doc: &Doc) -> NormView {
let mut b = Builder {
src,
out: NormView::default(),
};
for op in &doc.ops {
match op {
NormOp::Block => {
if !b.out.text.is_empty() && !b.out.text.ends_with('\n') {
let at = b.out.segs.last().map(|s| s.src.end).unwrap_or(0);
b.push_mapped(at..at, "\n", 0);
}
b.out.line_starts.push(b.out.text.len());
}
NormOp::Text { range, flags } => {
b.push_text(range.clone(), *flags);
}
NormOp::TextOwned {
range,
content,
flags,
} => {
b.push_mapped(range.clone(), content, *flags);
}
NormOp::Break { range, hard, flags } => {
b.push_mapped(range.clone(), if *hard { "\n" } else { " " }, *flags);
b.out.line_starts.push(b.out.text.len());
}
NormOp::Barrier { range, flags } => {
b.push_mapped(range.clone(), "\u{FFFD}", *flags);
}
}
}
fold_norm(b.out)
}
impl NormView {
pub fn to_source(&self, norm_range: Range<usize>) -> Option<Range<usize>> {
if norm_range.start >= norm_range.end || self.segs.is_empty() {
return None;
}
let first = self.seg_at(norm_range.start)?;
let last = self.seg_at(norm_range.end - 1)?;
let fs = &self.segs[first];
let ls = &self.segs[last];
let start = match fs.kind {
SegKind::Identity => fs.src.start + (norm_range.start - fs.norm.start),
SegKind::Mapped => fs.src.start,
};
let end = match ls.kind {
SegKind::Identity => ls.src.start + (norm_range.end - ls.norm.start),
SegKind::Mapped => ls.src.end,
};
if end < start {
return None;
}
Some(start..end)
}
pub fn source_span_norm_text(&self, src: &Range<usize>) -> String {
let mut out = String::new();
let first = self.segs.partition_point(|seg| seg.src.end <= src.start);
for seg in &self.segs[first..] {
if seg.src.start >= src.end {
break;
}
if src.start >= seg.src.end {
continue;
}
match seg.kind {
SegKind::Identity => {
let lo = seg.src.start.max(src.start);
let hi = seg.src.end.min(src.end);
let noff = seg.norm.start + (lo - seg.src.start);
let nend = seg.norm.start + (hi - seg.src.start);
out.push_str(&self.text[noff..nend]);
}
SegKind::Mapped => out.push_str(&self.text[seg.norm.clone()]),
}
}
out
}
pub fn flags_at(&self, norm_offset: usize) -> u8 {
self.seg_at(norm_offset)
.map(|i| self.segs[i].flags)
.unwrap_or(0)
}
pub fn span_has_flag(&self, norm_range: &Range<usize>, bit: u8) -> bool {
let Some(first) = self.seg_at(norm_range.start) else {
return false;
};
let Some(last) = self.seg_at(norm_range.end.saturating_sub(1)) else {
return false;
};
self.segs[first..=last].iter().any(|s| s.flags & bit != 0)
}
pub fn all_quoted(&self, norm_range: &Range<usize>) -> bool {
let Some(first) = self.seg_at(norm_range.start) else {
return false;
};
let Some(last) = self.seg_at(norm_range.end.saturating_sub(1)) else {
return false;
};
self.segs[first..=last]
.iter()
.filter(|s| s.norm.start < s.norm.end)
.all(|s| s.flags & crate::extract::F_QUOTED != 0)
}
fn seg_at(&self, norm_offset: usize) -> Option<usize> {
let mut lo = 0usize;
let mut hi = self.segs.len();
while lo < hi {
let mid = (lo + hi) / 2;
let s = &self.segs[mid];
if norm_offset < s.norm.start {
hi = mid;
} else if norm_offset >= s.norm.end {
lo = mid + 1;
} else {
return Some(mid);
}
}
(0..self.segs.len())
.rev()
.find(|&i| self.segs[i].norm.start <= norm_offset && !self.segs[i].norm.is_empty())
}
pub fn is_block_start(&self, norm_offset: usize) -> bool {
if norm_offset == 0 {
return true;
}
let skip = |c: char| c == ' ' || c == '\t' || is_leading_decoration(c);
let before = &self.text[..norm_offset];
let trimmed = before.trim_end_matches(skip);
if trimmed.is_empty() || trimmed.ends_with('\n') {
return true;
}
if trimmed.ends_with(['.', '!', '?', ':']) && trimmed.len() < before.len() {
return true;
}
if let Some(&ls) = self.line_starts.iter().rev().find(|&&ls| ls <= norm_offset) {
if self.text[ls..norm_offset]
.chars()
.all(|c| c == '\n' || skip(c))
{
return true;
}
}
false
}
}
pub(crate) const MARKER_BULLETS: [char; 7] = [
'-', '*', '+', '\u{2022}', '\u{2023}', '\u{2043}', '\u{2219}', ];
const DECORATION: &[std::ops::RangeInclusive<u32>] = &[
0x2022..=0x2023, 0x2043..=0x2043, 0x2219..=0x2219, 0x200D..=0x200D, 0x20E3..=0x20E3, 0xFE0E..=0xFE0F, 0x203C..=0x203C, 0x2049..=0x2049, 0x2122..=0x2122, 0x2139..=0x2139, 0x2190..=0x21FF, 0x2300..=0x23FF, 0x24C2..=0x24C2, 0x25A0..=0x25FF, 0x2600..=0x27BF, 0x2B00..=0x2BFF, 0x3030..=0x3030,
0x303D..=0x303D,
0x3297..=0x3297,
0x3299..=0x3299,
0x1F000..=0x1FAFF, ];
fn is_leading_decoration(c: char) -> bool {
let u = c as u32;
DECORATION.iter().any(|r| r.contains(&u))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::extract::{Doc, NormOp};
fn norm(src: &str, ops: Vec<NormOp>) -> NormView {
let doc = Doc {
ops,
..Default::default()
};
build_norm(src, &doc)
}
#[test]
fn numeric_ref_classification_is_arithmetic_and_fail_closed() {
use NumRef::*;
assert_eq!(classify_numeric_ref("d", 0), Decode(6, 'd'));
assert_eq!(classify_numeric_ref("e", 0), Decode(6, 'e'));
assert_eq!(classify_numeric_ref("e", 0), Decode(6, 'e'));
assert_eq!(classify_numeric_ref(" ", 0), Decode(5, ' '));
assert_eq!(classify_numeric_ref("xyd", 2), Decode(6, 'd'));
assert_eq!(classify_numeric_ref("©", 0), Decode(6, '\u{A9}'));
assert_eq!(classify_numeric_ref("é", 0), Decode(6, '\u{E9}'));
assert_eq!(classify_numeric_ref(" ", 0), Decode(6, ' '));
assert_eq!(classify_numeric_ref(" ", 0), Decode(7, ' '));
assert_eq!(classify_numeric_ref("​", 0), Suppress(8));
assert_eq!(classify_numeric_ref("​", 0), Suppress(7)); assert_eq!(classify_numeric_ref("	", 0), Suppress(4)); assert_eq!(classify_numeric_ref("", 0), Suppress(4)); assert_eq!(classify_numeric_ref("—", 0), Suppress(6)); assert_eq!(classify_numeric_ref("", 0), Suppress(8));
assert_eq!(classify_numeric_ref("‮", 0), Suppress(8));
assert_eq!(classify_numeric_ref("͏", 0), Suppress(6)); assert_eq!(classify_numeric_ref("️", 0), Suppress(8)); assert_eq!(classify_numeric_ref("­", 0), Suppress(6));
assert_eq!(classify_numeric_ref("d", 0), Overlong(11));
assert_eq!(classify_numeric_ref("©", 0), Overlong(13));
assert_eq!(classify_numeric_ref("�", 0), Overlong(11));
assert_eq!(classify_numeric_ref("&#", 0), Literal);
assert_eq!(classify_numeric_ref("&#;", 0), Literal);
assert_eq!(classify_numeric_ref("&#zz;", 0), Literal);
assert_eq!(classify_numeric_ref("&#xZZ;", 0), Literal);
assert_eq!(classify_numeric_ref("&", 0), Literal);
assert_eq!(classify_numeric_ref("delve", 0), Literal); assert_eq!(classify_numeric_ref("�", 0), Decode(8, '\u{FFFD}'));
}
#[test]
fn numeric_ref_flows_through_norm_view_and_render_key() {
let nv = norm(
"delve",
vec![NormOp::Text {
range: 0..10,
flags: 0,
}],
);
assert_eq!(nv.text, "delve");
assert_eq!(render_key("delve"), "delve");
assert_eq!(render_key("delve"), "delve");
assert_eq!(render_key("game changer"), "game changer");
assert_eq!(render_key("a&#b"), "a&#b");
}
#[test]
fn source_span_norm_text_clips_identity_segments() {
let nv = norm(
"delve parser",
vec![NormOp::Text {
range: 0..12,
flags: 0,
}],
);
assert_eq!(nv.text, "delve parser");
assert_eq!(nv.source_span_norm_text(&(6..7)), "p");
assert!(!nv.source_span_norm_text(&(6..12)).contains("delve"));
}
#[test]
fn fold_pass_preserves_source_mapping_and_flags() {
let nv = norm(
"d\u{0435}lve",
vec![NormOp::Text {
range: 0..6,
flags: 0,
}],
);
assert_eq!(nv.text, "delve");
assert_eq!(nv.to_source(1..2), Some(1..3));
assert!(!nv.span_has_flag(&(0..5), crate::extract::F_FULL_FOLD));
let src = "моѕаіс";
let nv = norm(
src,
vec![NormOp::Text {
range: 0..src.len(),
flags: 0,
}],
);
assert_eq!(nv.text, "mosaic");
assert!(nv.span_has_flag(&(0..6), crate::extract::F_FULL_FOLD));
let src = "\u{FF4D}\u{FF4F}\u{FF53}\u{FF41}\u{FF49}\u{FF43}";
let nv = norm(
src,
vec![NormOp::Text {
range: 0..src.len(),
flags: 0,
}],
);
assert_eq!(nv.text, "mosaic");
assert!(!nv.span_has_flag(&(0..6), crate::extract::F_FULL_FOLD));
assert_eq!(
render_key("\u{FF4D}\u{FF4F}\u{FF53}\u{FF41}\u{FF49}\u{FF43}"),
"mosaic"
);
assert_eq!(render_key("моѕаіс"), "mosaic");
let nv = norm(
"3½ cups",
vec![NormOp::Text {
range: 0..8,
flags: 0,
}],
);
assert!(nv.text.contains('½'));
}
#[test]
fn homoglyphs_fold_only_in_mixed_script_tokens() {
let nv = norm(
"d\u{0435}lve",
vec![NormOp::Text {
range: 0..6,
flags: 0,
}],
);
assert_eq!(nv.text, "delve");
assert_eq!(render_key("d\u{0435}lve"), "delve");
let src = "делве";
let nv = norm(
src,
vec![NormOp::Text {
range: 0..src.len(),
flags: 0,
}],
);
assert_eq!(nv.text, "делве");
assert_eq!(render_key("делве"), "делве");
let nv = norm(
"café",
vec![NormOp::Text {
range: 0..5,
flags: 0,
}],
);
assert_eq!(nv.text, "café");
}
#[test]
fn source_span_norm_text_keeps_whole_mapped_segment() {
let nv = norm(
"a—b",
vec![NormOp::Text {
range: 0..9,
flags: 0,
}],
);
assert_eq!(nv.text, "a\u{2014}b");
assert!(nv.source_span_norm_text(&(3..4)).contains('\u{2014}'));
}
}