use std::cmp::Ordering;
fn primary(c: char) -> (u8, u32) {
const PUNCTUATION: &str = "_-,;:!?.'\"()[]{}@*/\\&#%`^+<=>|~$";
if c.is_whitespace() {
(0, c as u32)
} else if let Some(rank) = PUNCTUATION.find(c) {
(1, rank as u32)
} else if c.is_ascii_digit() {
(2, c as u32)
} else if c.is_alphabetic() {
letter(c.to_lowercase().next().unwrap_or(c), 0)
} else {
(4, c as u32)
}
}
fn letter(letter: char, after: u8) -> (u8, u32) {
(3, (letter as u32) << 8 | u32::from(after))
}
enum Weight {
Letter(char, u8, u8),
Accent(u32, u8),
}
use Weight::{Accent as A, Letter as L};
const LETTERS: &[(char, &[Weight])] = &[
('Æ', &[L('a', 0, 10), A(0x118, 4), L('e', 0, 10)]),
('Ð', &[L('d', 0, 10), A(0x118, 4)]),
('Ø', &[L('o', 0, 8), A(0x2F, 2)]),
('Þ', &[L('z', 1, 8)]),
('ß', &[L('s', 0, 4), A(0x118, 4), L('s', 0, 4)]),
('æ', &[L('a', 0, 4), A(0x118, 4), L('e', 0, 4)]),
('ð', &[L('d', 0, 4), A(0x118, 4)]),
('ø', &[L('o', 0, 2), A(0x2F, 2)]),
('þ', &[L('z', 1, 2)]),
('Đ', &[L('d', 0, 8), A(0x39, 2)]),
('đ', &[L('d', 0, 2), A(0x39, 2)]),
('Ħ', &[L('h', 0, 8), A(0x39, 2)]),
('ħ', &[L('h', 0, 2), A(0x39, 2)]),
('ı', &[L('i', 1, 2)]),
('IJ', &[L('i', 0, 10), L('j', 0, 10)]),
('ij', &[L('i', 0, 4), L('j', 0, 4)]),
('ĸ', &[L('q', 1, 2)]),
('Ŀ', &[L('l', 0, 8), A(0x118, 2)]),
('ŀ', &[L('l', 0, 2), A(0x118, 2)]),
('Ł', &[L('l', 0, 8), A(0x39, 2)]),
('ł', &[L('l', 0, 2), A(0x39, 2)]),
('ʼn', &[L('z', 2, 4), L('n', 0, 4)]),
('Ŋ', &[L('n', 1, 8)]),
('ŋ', &[L('n', 1, 2)]),
('Œ', &[L('o', 0, 10), A(0x118, 4), L('e', 0, 10)]),
('œ', &[L('o', 0, 4), A(0x118, 4), L('e', 0, 4)]),
('Ŧ', &[L('t', 1, 8)]),
('ŧ', &[L('t', 1, 2)]),
('ſ', &[L('s', 0, 4), A(0x119, 4)]),
('ẞ', &[L('s', 0, 10), A(0x118, 4), L('s', 0, 10)]),
];
const MARKS: [u8; 0x63] = [
0x25, 0x24, 0x27, 0x2D, 0x32, 0x3A, 0x26, 0x2E, 0x2B, 0x3B, 0x29, 0x2C, 0x28, 0x33, 0x33, 0x3C,
0x3D, 0x3E, 0x33, 0x22, 0x23, 0x33, 0x34, 0x34, 0x34, 0x34, 0x33, 0x3F, 0x34, 0x34, 0x34, 0x34,
0x34, 0x40, 0x41, 0x42, 0x43, 0x44, 0x45, 0x30, 0x31, 0x34, 0x34, 0x34, 0x34, 0x46, 0x47, 0x34,
0x48, 0x49, 0x21, 0x34, 0x4A, 0x39, 0x35, 0x35, 0x2F, 0x4B, 0x34, 0x34, 0x34, 0x33, 0x33, 0x33,
0x25, 0x24, 0x2A, 0x22, 0x2B, 0x4C, 0x33, 0x34, 0x34, 0x34, 0x33, 0x33, 0x33, 0x34, 0x34, 0x0,
0x33, 0x33, 0x33, 0x34, 0x34, 0x34, 0x34, 0x33, 0x4D, 0x34, 0x34, 0x33, 0x34, 0x33, 0x33, 0x34,
0x4E, 0x4F, 0x34,
];
const COMMON: (u32, u8) = (0x20, 2);
type Element = (Option<(u8, u32)>, u32, u8);
fn elements(text: &str) -> Vec<Element> {
use unicode_normalization::UnicodeNormalization;
let mut elements = Vec::new();
for c in text.nfd() {
if let Ok(index) = LETTERS.binary_search_by_key(&c, |(letter, _)| *letter) {
elements.extend(LETTERS[index].1.iter().map(|weight| match *weight {
L(base, after, tertiary) => (Some(letter(base, after)), COMMON.0, tertiary),
A(secondary, tertiary) => (None, secondary, tertiary),
}));
} else if unicode_normalization::char::is_combining_mark(c) {
let secondary = (c as usize)
.checked_sub(0x300)
.and_then(|index| MARKS.get(index))
.map_or(0x200 + c as u32, |&weight| u32::from(weight));
if secondary != 0 {
elements.push((None, secondary, COMMON.1));
}
} else {
let tertiary = if c.is_uppercase() { 8 } else { COMMON.1 };
elements.push((Some(primary(c)), COMMON.0, tertiary));
}
}
elements
}
pub fn locale_compare(a: &str, b: &str) -> Ordering {
let (a_elements, b_elements) = (elements(a), elements(b));
let primaries = |elements: &[Element]| elements.iter().filter_map(|e| e.0).collect::<Vec<_>>();
let secondaries = |elements: &[Element]| elements.iter().map(|e| e.1).collect::<Vec<_>>();
let tertiaries = |elements: &[Element]| elements.iter().map(|e| e.2).collect::<Vec<_>>();
primaries(&a_elements)
.cmp(&primaries(&b_elements))
.then_with(|| secondaries(&a_elements).cmp(&secondaries(&b_elements)))
.then_with(|| tertiaries(&a_elements).cmp(&tertiaries(&b_elements)))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sorts_like_locale_compare() {
let mut ids = vec![
"gpt-4o",
"gpt-4.1",
"GPT-4",
"gpt-4",
"gpt-40",
"a~b",
"a:b",
"a/b",
"a@b",
"a-b",
"Zed.txt",
"ünï cödé.txt",
"été",
"Été",
"ete",
"être",
"côté",
"cote",
"coté",
"côte",
"e\u{301}tf",
"Ångström",
"angle",
"ørsted",
"oz",
"straße",
"strasse",
"stras",
"Łódź",
"lz",
"æble",
"aeble",
"afar",
"þorn",
"zebra",
"ŋa",
"nz",
"đuro",
"dz",
];
ids.sort_by(|a, b| locale_compare(a, b));
assert_eq!(
ids,
[
"a-b",
"a:b",
"a@b",
"a/b",
"a~b",
"aeble",
"æble",
"afar",
"angle",
"Ångström",
"cote",
"coté",
"côte",
"côté",
"đuro",
"dz",
"ete",
"été",
"Été",
"e\u{301}tf",
"être",
"gpt-4",
"GPT-4",
"gpt-4.1",
"gpt-40",
"gpt-4o",
"Łódź",
"lz",
"nz",
"ŋa",
"ørsted",
"oz",
"stras",
"strasse",
"straße",
"ünï cödé.txt",
"zebra",
"Zed.txt",
"þorn"
]
);
assert_eq!(locale_compare("đ", "d\u{335}"), Ordering::Equal);
assert!(LETTERS.windows(2).all(|pair| pair[0].0 < pair[1].0));
}
}