mod tables;
#[cfg(test)]
mod tests;
use crate::{core_props, rare_props};
use tables::{
ACCENTS, CASE_DELTAS, CASE_EXCEPTIONS, CASE_RUN_DATA, CASE_RUNS, CASE_STRINGS, GREEK_ACCENT,
GREEK_DIALYTIKA, GREEK_LETTERS, GREEK_UPPER_MASK, GREEK_VOWEL, GREEK_YPOGEGRAMMENI,
RUN_EXCEPTIONS, RUN_KIND_MASK, RUN_LENGTH_MASK, RUN_LENGTH_SHIFT, RUN_LOWER, RUN_PAIRS,
RUN_START_SHIFT, RUN_UPPER, SOFT_DOTTED,
};
#[derive(Copy, Clone, PartialEq, Eq, Hash, Debug)]
pub enum CaseLanguage {
Root,
Turkic,
Lithuanian,
Greek,
Dutch,
}
impl CaseLanguage {
pub fn from_language(language: &str) -> Self {
match language {
"tr" | "az" => Self::Turkic,
"lt" => Self::Lithuanian,
"el" => Self::Greek,
"nl" => Self::Dutch,
_ => Self::Root,
}
}
}
#[derive(Copy, Clone, PartialEq, Eq)]
enum Mapping {
Lower = 0,
Upper = 1,
Title = 2,
}
const DOT_ABOVE: char = '\u{307}';
const ACUTE: char = '\u{301}';
pub fn uppercase(text: &str, language: CaseLanguage, push: &mut dyn FnMut(char)) {
if language == CaseLanguage::Greek {
greek_uppercase(text, push);
return;
}
for (at, ch) in text.char_indices() {
match ch {
'i' if language == CaseLanguage::Turkic => push('\u{130}'),
_ if ch.is_ascii() => push(ch.to_ascii_uppercase()),
DOT_ABOVE
if language == CaseLanguage::Lithuanian
&& follows_soft_dotted(text.get(..at).unwrap_or_default()) => {}
_ => upper(ch, push),
}
}
}
pub fn lowercase(text: &str, language: CaseLanguage, push: &mut dyn FnMut(char)) {
let lithuanian = language == CaseLanguage::Lithuanian;
let turkic = language == CaseLanguage::Turkic;
for (at, ch) in text.char_indices() {
let before = || text.get(..at).unwrap_or_default();
let after = || text.get(at + ch.len_utf8()..).unwrap_or_default();
match ch {
'I' | 'J' | '\u{12E}' if lithuanian && is_followed_by_more_above(after()) => {
map(ch, Mapping::Lower, push);
push(DOT_ABOVE);
}
'\u{CC}' | '\u{CD}' | '\u{128}' if lithuanian => {
push('i');
push(DOT_ABOVE);
push(match ch {
'\u{CC}' => '\u{300}',
'\u{CD}' => ACUTE,
_ => '\u{303}',
});
}
'\u{130}' if turkic => push('i'),
DOT_ABOVE if turkic && follows_capital_i(before()) => {}
'I' if turkic && !is_followed_by_dot_above(after()) => push('\u{131}'),
'\u{3A3}'
if !is_next_cased(after().chars()) && is_next_cased(before().chars().rev()) =>
{
push('\u{3C2}');
}
_ if ch.is_ascii() => push(ch.to_ascii_lowercase()),
_ => map(ch, Mapping::Lower, push),
}
}
}
pub fn titlecase(segment: &str, language: CaseLanguage, push: &mut dyn FnMut(char)) {
let mut chars = segment.chars();
let Some(first) = chars.next() else {
return;
};
let rest = chars.as_str();
let mut title = None;
if first == 'i' && language == CaseLanguage::Turkic {
push('\u{130}');
} else {
let mut count = 0;
map(first, Mapping::Title, &mut |ch| {
count += 1;
title = (count == 1).then_some(ch);
push(ch);
});
}
let j = match title {
Some(title @ ('I' | '\u{CD}')) if language == CaseLanguage::Dutch => {
dutch_j(rest, title == '\u{CD}')
}
_ => None,
};
for (at, ch) in rest.char_indices() {
push(if Some(at) == j { 'J' } else { ch });
}
}
fn dutch_j(rest: &str, mut acute: bool) -> Option<usize> {
let mut chars = rest.char_indices();
let (mut at, mut ch) = chars.next()?;
if !acute && ch == ACUTE {
acute = true;
(at, ch) = chars.next()?;
}
if !matches!(ch, 'j' | 'J') {
return None;
}
if acute && chars.next()?.1 != ACUTE {
return None;
}
if chars
.next()
.is_some_and(|(_, next)| core_props(next).is_mark())
{
return None;
}
(ch == 'j').then_some(at)
}
fn upper(ch: char, push: &mut dyn FnMut(char)) {
match ch {
'\u{1C90}'..='\u{1CBF}' => push(char::from_u32(u32::from(ch) - 0xBC0).unwrap_or(ch)),
_ => map(ch, Mapping::Upper, push),
}
}
fn map(ch: char, mapping: Mapping, push: &mut dyn FnMut(char)) {
let cp = u32::from(ch);
let at = CASE_RUNS.partition_point(|&run| run >> RUN_START_SHIFT <= cp);
let Some((&run, &data)) = at
.checked_sub(1)
.and_then(|at| CASE_RUNS.get(at).zip(CASE_RUN_DATA.get(at)))
else {
push(ch);
return;
};
let offset = cp - (run >> RUN_START_SHIFT);
if offset > (run >> RUN_LENGTH_SHIFT) & RUN_LENGTH_MASK {
push(ch);
return;
}
let delta = || CASE_DELTAS.get(usize::from(data)).copied().unwrap_or(0);
let delta = match (run & RUN_KIND_MASK, mapping) {
(RUN_LOWER, Mapping::Upper | Mapping::Title) => delta(),
(RUN_UPPER, Mapping::Lower) => delta(),
(RUN_PAIRS, Mapping::Lower) if offset.is_multiple_of(2) => 1,
(RUN_PAIRS, Mapping::Upper | Mapping::Title) if !offset.is_multiple_of(2) => -1,
(RUN_EXCEPTIONS, _) => {
let row = (usize::from(data) + offset as usize) * 3 + mapping as usize;
let spelled = CASE_EXCEPTIONS.get(row).copied().unwrap_or(0);
let start = usize::from(spelled >> 2);
let units = CASE_STRINGS
.get(start..start + usize::from(spelled & 3))
.unwrap_or_default();
if units.is_empty() {
push(ch);
}
for &unit in units {
push(char::from_u32(u32::from(unit)).unwrap_or(ch));
}
return;
}
_ => 0,
};
push(char::from_u32(cp.wrapping_add_signed(delta)).unwrap_or(ch));
}
fn is_next_cased(chars: impl Iterator<Item = char>) -> bool {
chars
.map(rare_props)
.find(|props| !props.is_case_ignorable())
.is_some_and(|props| props.is_cased())
}
#[derive(Copy, Clone, PartialEq, Eq)]
enum Dot {
None,
SoftDotted,
Above,
OtherAccent,
}
fn dot(ch: char) -> Dot {
let cp = u32::from(ch);
let at = SOFT_DOTTED.partition_point(|&range| range >> 8 <= cp);
let soft_dotted = at
.checked_sub(1)
.and_then(|at| SOFT_DOTTED.get(at))
.is_some_and(|&range| cp - (range >> 8) <= range & 0xFF);
if soft_dotted {
return Dot::SoftDotted;
}
let at = ACCENTS.partition_point(|&range| range >> 8 <= cp);
match at.checked_sub(1).and_then(|at| ACCENTS.get(at)) {
Some(&range) if cp - (range >> 8) <= (range >> 1) & 0x7F => {
if range & 1 != 0 {
Dot::Above
} else {
Dot::OtherAccent
}
}
_ => Dot::None,
}
}
fn follows_soft_dotted(before: &str) -> bool {
before
.chars()
.rev()
.map(dot)
.find(|&dot| dot != Dot::OtherAccent)
== Some(Dot::SoftDotted)
}
fn follows_capital_i(before: &str) -> bool {
before
.chars()
.rev()
.find(|&ch| ch == 'I' || dot(ch) != Dot::OtherAccent)
== Some('I')
}
fn is_followed_by_more_above(after: &str) -> bool {
after.chars().map(dot).find(|&dot| dot != Dot::OtherAccent) == Some(Dot::Above)
}
fn is_followed_by_dot_above(after: &str) -> bool {
after
.chars()
.find(|&ch| ch == DOT_ABOVE || dot(ch) != Dot::OtherAccent)
== Some(DOT_ABOVE)
}
const GREEK_COMBINING_DIALYTIKA: u32 = 1 << 14;
const GREEK_OTHER_DIACRITIC: u32 = 1 << 15;
const AFTER_CASED: u32 = 1;
const AFTER_VOWEL_WITH_COMBINING_ACCENT: u32 = 2;
const AFTER_VOWEL_WITH_PRECOMPOSED_ACCENT: u32 = 4;
fn greek_diacritic(ch: char) -> u32 {
match ch {
'\u{300}' | '\u{301}' | '\u{342}' | '\u{302}' | '\u{303}' | '\u{311}' => GREEK_ACCENT,
'\u{308}' => GREEK_COMBINING_DIALYTIKA,
'\u{344}' => GREEK_COMBINING_DIALYTIKA | GREEK_ACCENT,
'\u{345}' => GREEK_YPOGEGRAMMENI,
'\u{304}' | '\u{306}' | '\u{313}' | '\u{314}' | '\u{343}' => GREEK_OTHER_DIACRITIC,
_ => 0,
}
}
fn greek_letter(ch: char) -> u32 {
let index = match u32::from(ch) {
cp @ 0x370..=0x3FF => cp - 0x370,
cp @ 0x1F00..=0x1FFF => cp - 0x1F00 + 0x90,
0x2126 => 0x3A9 - 0x370,
_ => return 0,
};
GREEK_LETTERS
.get(index as usize)
.map_or(0, |&entry| u32::from(entry))
}
#[cold]
#[inline(never)]
fn greek_uppercase(text: &str, push: &mut dyn FnMut(char)) {
const EITHER_DIALYTIKA: u32 = GREEK_DIALYTIKA | GREEK_COMBINING_DIALYTIKA;
const AFTER_ACCENT: u32 =
AFTER_VOWEL_WITH_COMBINING_ACCENT | AFTER_VOWEL_WITH_PRECOMPOSED_ACCENT;
let mut state = 0;
let mut at = 0;
while let Some(ch) = text.get(at..).and_then(|rest| rest.chars().next()) {
let mut next = at + ch.len_utf8();
let props = rare_props(ch);
let mut next_state = 0;
if props.is_case_ignorable() {
next_state |= state & AFTER_CASED;
} else if props.is_cased() {
next_state |= AFTER_CASED;
}
let mut data = greek_letter(ch);
if data == 0 {
upper(ch, push);
at = next;
state = next_state;
continue;
}
let mut capital = data & GREEK_UPPER_MASK;
if data & GREEK_VOWEL != 0 && state & AFTER_ACCENT != 0 && matches!(capital, 0x399 | 0x3A5)
{
data |= if state & AFTER_VOWEL_WITH_PRECOMPOSED_ACCENT != 0 {
GREEK_DIALYTIKA
} else {
GREEK_COMBINING_DIALYTIKA
};
}
let mut iotas = u32::from(data & GREEK_YPOGEGRAMMENI != 0);
let precomposed_accent = data & GREEK_ACCENT != 0;
while let Some(mark) = text.get(next..).and_then(|rest| rest.chars().next()) {
let flags = greek_diacritic(mark);
if flags == 0 {
break;
}
data |= flags;
iotas += u32::from(flags & GREEK_YPOGEGRAMMENI != 0);
next += mark.len_utf8();
}
if data & (GREEK_VOWEL | GREEK_ACCENT | GREEK_DIALYTIKA) == GREEK_VOWEL | GREEK_ACCENT {
next_state |= if precomposed_accent {
AFTER_VOWEL_WITH_PRECOMPOSED_ACCENT
} else {
AFTER_VOWEL_WITH_COMBINING_ACCENT
};
}
let mut tonos = false;
if capital == 0x397
&& data & GREEK_ACCENT != 0
&& iotas == 0
&& state & AFTER_CASED == 0
&& !is_next_cased(text.get(next..).unwrap_or_default().chars())
{
if precomposed_accent {
capital = 0x389;
} else {
tonos = true;
}
} else if data & GREEK_DIALYTIKA != 0 && matches!(capital, 0x399 | 0x3A5) {
capital = if capital == 0x399 { 0x3AA } else { 0x3AB };
data &= !EITHER_DIALYTIKA;
}
push(char::from_u32(capital).unwrap_or(ch));
if data & EITHER_DIALYTIKA != 0 {
push('\u{308}');
}
if tonos {
push(ACUTE);
}
for _ in 0..iotas {
push('\u{399}');
}
at = next;
state = next_state;
}
}