use std::cmp::Ordering;
use std::sync::OnceLock;
const CASING_TABLES: &str = include_str!("../data/casing_tables.tsv");
const CAPITAL_SIGMA: char = '\u{03A3}';
const SMALL_SIGMA: char = '\u{03C3}';
const FINAL_SIGMA: char = '\u{03C2}';
pub(crate) struct CasingTables {
isupper_false: Vec<(u32, u32)>,
isupper_cased: Vec<(u32, u32)>,
case_ignorable: Vec<(u32, u32)>,
cased: Vec<(u32, u32)>,
lower: Vec<(u32, String)>,
}
impl CasingTables {
pub(crate) fn parse(raw: &str) -> CasingTables {
let mut tables = CasingTables {
isupper_false: Vec::new(),
isupper_cased: Vec::new(),
case_ignorable: Vec::new(),
cased: Vec::new(),
lower: Vec::new(),
};
for line in raw.lines() {
if line.is_empty() || line.as_bytes().first() == Some(&b'#') {
continue;
}
let mut fields = line.split('\t');
let (Some(kind), Some(a), Some(b)) = (fields.next(), fields.next(), fields.next())
else {
continue;
};
let Ok(a) = u32::from_str_radix(a, 16) else {
continue;
};
if kind == "lower" {
let mapped: String = b
.split(' ')
.filter_map(|cp| u32::from_str_radix(cp, 16).ok())
.filter_map(char::from_u32)
.collect();
tables.lower.push((a, mapped));
continue;
}
let Ok(b) = u32::from_str_radix(b, 16) else {
continue;
};
match kind {
"isupper_false" => tables.isupper_false.push((a, b)),
"isupper_cased" => tables.isupper_cased.push((a, b)),
"case_ignorable" => tables.case_ignorable.push((a, b)),
"cased" => tables.cased.push((a, b)),
_ => {}
}
}
tables.isupper_false.sort_unstable();
tables.isupper_cased.sort_unstable();
tables.case_ignorable.sort_unstable();
tables.cased.sort_unstable();
tables.lower.sort_unstable_by_key(|&(cp, _)| cp);
tables
}
fn is_isupper_false(&self, c: char) -> bool {
in_ranges(&self.isupper_false, c)
}
fn is_isupper_cased(&self, c: char) -> bool {
in_ranges(&self.isupper_cased, c)
}
fn is_case_ignorable(&self, c: char) -> bool {
in_ranges(&self.case_ignorable, c)
}
fn is_cased(&self, c: char) -> bool {
in_ranges(&self.cased, c)
}
fn lower_of(&self, c: char) -> Option<&str> {
let idx = self
.lower
.binary_search_by_key(&(c as u32), |&(cp, _)| cp)
.ok()?;
self.lower.get(idx).map(|(_, mapped)| mapped.as_str())
}
}
fn in_ranges(ranges: &[(u32, u32)], c: char) -> bool {
let cp = c as u32;
ranges
.binary_search_by(|&(start, end)| {
if end < cp {
Ordering::Less
} else if start > cp {
Ordering::Greater
} else {
Ordering::Equal
}
})
.is_ok()
}
fn tables() -> &'static CasingTables {
static TABLES: OnceLock<CasingTables> = OnceLock::new();
TABLES.get_or_init(|| CasingTables::parse(CASING_TABLES))
}
pub(crate) fn py_isupper(s: &str) -> bool {
let t = tables();
let mut has_cased = false;
for c in s.chars() {
if t.is_isupper_false(c) {
return false;
}
if t.is_isupper_cased(c) {
has_cased = true;
}
}
has_cased
}
pub(crate) fn py_lower(s: &str) -> String {
let t = tables();
let chars: Vec<char> = s.chars().collect();
let mut out = String::with_capacity(s.len());
for (i, &c) in chars.iter().enumerate() {
if c == CAPITAL_SIGMA {
let sigma = if final_sigma(t, &chars, i) {
FINAL_SIGMA
} else {
SMALL_SIGMA
};
out.push(sigma);
} else if let Some(mapped) = t.lower_of(c) {
out.push_str(mapped);
} else {
out.push(c);
}
}
out
}
fn final_sigma(t: &CasingTables, chars: &[char], i: usize) -> bool {
let before = chars
.iter()
.take(i)
.rev()
.find(|&&c| !t.is_case_ignorable(c));
if !before.is_some_and(|&c| t.is_cased(c)) {
return false;
}
let after = chars
.iter()
.skip(i.saturating_add(1))
.find(|&&c| !t.is_case_ignorable(c));
!after.is_some_and(|&c| t.is_cased(c))
}
#[cfg(test)]
mod tests {
use super::{py_isupper, py_lower, tables, CasingTables, CASING_TABLES};
use crate::text::is_py_space;
use sha2::{Digest, Sha256};
use std::fmt::Write as _;
#[test]
fn isupper_matches_cpython() {
assert!(py_isupper("VADER"));
assert!(py_isupper("V1"));
assert!(!py_isupper("Vader"));
assert!(!py_isupper("vader"));
assert!(!py_isupper("123"));
assert!(!py_isupper(":)"));
assert!(!py_isupper(""));
assert!(!py_isupper("A\u{01C5}"));
assert!(!py_isupper("\u{01C5}"));
assert!(!py_isupper("\u{A7CE}"));
assert!(py_isupper("GOOD\u{A7CE}"));
assert!(py_isupper("\u{24B6}"));
}
#[test]
fn lower_matches_cpython() {
assert_eq!(py_lower("GOOD"), "good");
assert_eq!(py_lower("don't"), "don't");
assert_eq!(py_lower(""), "");
assert_eq!(py_lower("\u{A7CE}1!"), "\u{A7CE}1!");
assert_eq!(py_lower("\u{0130}"), "i\u{0307}");
assert_eq!(py_lower("\u{1E9E}"), "\u{00DF}");
assert_eq!(py_lower("\u{00DF}\u{FB01}"), "\u{00DF}\u{FB01}");
}
#[test]
fn sigma_context_matches_cpython() {
assert_eq!(py_lower("\u{03A3}"), "\u{03C3}");
assert_eq!(py_lower("\u{03A3}\u{03A3}"), "\u{03C3}\u{03C2}");
assert_eq!(py_lower("A\u{03A3}B"), "a\u{03C3}b");
assert_eq!(py_lower("A\u{03A3}"), "a\u{03C2}");
assert_eq!(py_lower("A\u{03A3}!"), "a\u{03C2}!");
assert_eq!(py_lower("!\u{03A3}"), "!\u{03C3}");
assert_eq!(py_lower("A\u{0345}\u{03A3}"), "a\u{0345}\u{03C2}");
assert_eq!(py_lower("A\u{03A3}\u{0345}B"), "a\u{03C3}\u{0345}b");
assert_eq!(py_lower("A\u{03A3}\u{0345}!"), "a\u{03C2}\u{0345}!");
assert_eq!(py_lower("\u{0345}\u{03A3}"), "\u{0345}\u{03C3}");
}
#[test]
fn parser_skips_junk_and_sorts() {
let t = CasingTables::parse(
"# comment\n\nisupper_cased\t61\t7a\nisupper_cased\t41\t5a\nlower\t42\t62\nlower\t41\t61 300\nlower\tzz\t61\nmystery\t1\t2\nisupper_false\tqq\t2\ncased\t1\tzz\nshort\n",
);
assert!(t.is_isupper_cased('B'));
assert!(t.is_isupper_cased('b'));
assert!(!t.is_isupper_cased('!'));
assert!(!t.is_isupper_false('a'));
assert_eq!(t.lower_of('A'), Some("a\u{300}"));
assert_eq!(t.lower_of('B'), Some("b"));
assert_eq!(t.lower_of('C'), None);
}
#[allow(clippy::chars_last_cmp)]
#[test]
fn all_codepoints_match_the_recorded_oracle_sweep() {
let recorded = CASING_TABLES
.lines()
.find_map(|l| l.strip_prefix("# sweep-sha256\t"))
.map(str::to_string);
assert!(recorded.is_some(), "no sweep digest in casing_tables.tsv");
let mut serial = String::new();
for cp in 0..=0x10FFFF_u32 {
let Some(c) = char::from_u32(cp) else {
continue; };
let u1 = py_isupper(&String::from(c));
let u2 = py_isupper(&format!("{c}A"));
let low = py_lower(&String::from(c));
let s1 = py_lower(&format!("A{c}\u{03A3}")).chars().next_back() == Some('\u{03C2}');
let s2 = py_lower(&format!("{c}\u{03A3}")).chars().next_back() == Some('\u{03C2}');
let sp = is_py_space(c);
let _ = write!(serial, "{cp:06x}\t");
for (k, mc) in low.chars().enumerate() {
if k > 0 {
serial.push(' ');
}
let _ = write!(serial, "{:x}", mc as u32);
}
let _ = writeln!(
serial,
"\t{}{}{}{}{}",
u32::from(u1),
u32::from(u2),
u32::from(s1),
u32::from(s2),
u32::from(sp)
);
}
let computed = format!("{:x}", Sha256::digest(serial.as_bytes()));
assert_eq!(
recorded.unwrap_or_default(),
computed,
"casing predicates diverge from the recorded oracle sweep"
);
}
#[test]
fn table_header_names_the_pinned_interpreter() {
assert!(CASING_TABLES.lines().any(|l| l == "# cpython\t3.14.6"));
assert!(CASING_TABLES.lines().any(|l| l == "# unidata\t16.0.0"));
let t = tables();
assert!(t.is_isupper_false('a'));
assert!(t.is_isupper_cased('A'));
assert!(t.is_case_ignorable('\u{0345}'));
assert!(t.is_cased('a'));
}
}