Skip to main content

yapi_types/
collate.rs

1//! String ordering as JavaScript's `localeCompare` gives it for identifiers and
2//! file names.
3
4use std::cmp::Ordering;
5
6/// Primary weight of a character in ICU (International Components for
7/// Unicode) root collation: whitespace, then ASCII punctuation and symbols in
8/// DUCET (Default Unicode Collation Element Table) order, then digits, then
9/// letters without case, and then any other character by code point.
10fn primary(c: char) -> (u8, u32) {
11    const PUNCTUATION: &str = "_-,;:!?.'\"()[]{}@*/\\&#%`^+<=>|~$";
12    if c.is_whitespace() {
13        (0, c as u32)
14    } else if let Some(rank) = PUNCTUATION.find(c) {
15        (1, rank as u32)
16    } else if c.is_ascii_digit() {
17        (2, c as u32)
18    } else if c.is_alphabetic() {
19        letter(c.to_lowercase().next().unwrap_or(c), 0)
20    } else {
21        (4, c as u32)
22    }
23}
24
25/// The primary weight of the `after`th letter ICU places after `letter`, or
26/// of `letter` itself for 0.
27fn letter(letter: char, after: u8) -> (u8, u32) {
28    (3, (letter as u32) << 8 | u32::from(after))
29}
30
31/// A collation element of a [`LETTERS`] entry, with DUCET's tertiary weight.
32enum Weight {
33    /// The `.1`th letter after `.0` (0 for `.0` itself).
34    Letter(char, u8, u8),
35    /// An accent's secondary weight.
36    Accent(u32, u8),
37}
38
39use Weight::{Accent as A, Letter as L};
40
41/// The Latin letters of U+00C0 to U+017F, and ẞ, that have no canonical
42/// decomposition, with their collation elements from DUCET 13: a stroke is
43/// an accent, a ligature is its letters, and þ, ı, ŋ and similar letters
44/// sort after a base letter. Sorted by letter.
45const LETTERS: &[(char, &[Weight])] = &[
46    ('Æ', &[L('a', 0, 10), A(0x118, 4), L('e', 0, 10)]),
47    ('Ð', &[L('d', 0, 10), A(0x118, 4)]),
48    ('Ø', &[L('o', 0, 8), A(0x2F, 2)]),
49    ('Þ', &[L('z', 1, 8)]),
50    ('ß', &[L('s', 0, 4), A(0x118, 4), L('s', 0, 4)]),
51    ('æ', &[L('a', 0, 4), A(0x118, 4), L('e', 0, 4)]),
52    ('ð', &[L('d', 0, 4), A(0x118, 4)]),
53    ('ø', &[L('o', 0, 2), A(0x2F, 2)]),
54    ('þ', &[L('z', 1, 2)]),
55    ('Đ', &[L('d', 0, 8), A(0x39, 2)]),
56    ('đ', &[L('d', 0, 2), A(0x39, 2)]),
57    ('Ħ', &[L('h', 0, 8), A(0x39, 2)]),
58    ('ħ', &[L('h', 0, 2), A(0x39, 2)]),
59    ('ı', &[L('i', 1, 2)]),
60    ('IJ', &[L('i', 0, 10), L('j', 0, 10)]),
61    ('ij', &[L('i', 0, 4), L('j', 0, 4)]),
62    ('ĸ', &[L('q', 1, 2)]),
63    ('Ŀ', &[L('l', 0, 8), A(0x118, 2)]),
64    ('ŀ', &[L('l', 0, 2), A(0x118, 2)]),
65    ('Ł', &[L('l', 0, 8), A(0x39, 2)]),
66    ('ł', &[L('l', 0, 2), A(0x39, 2)]),
67    ('ʼn', &[L('z', 2, 4), L('n', 0, 4)]),
68    ('Ŋ', &[L('n', 1, 8)]),
69    ('ŋ', &[L('n', 1, 2)]),
70    ('Œ', &[L('o', 0, 10), A(0x118, 4), L('e', 0, 10)]),
71    ('œ', &[L('o', 0, 4), A(0x118, 4), L('e', 0, 4)]),
72    ('Ŧ', &[L('t', 1, 8)]),
73    ('ŧ', &[L('t', 1, 2)]),
74    ('ſ', &[L('s', 0, 4), A(0x119, 4)]),
75    ('ẞ', &[L('s', 0, 10), A(0x118, 4), L('s', 0, 10)]),
76];
77
78/// The DUCET secondary weights of the combining marks U+0300 to U+0362, or 0
79/// for one that is ignored.
80const MARKS: [u8; 0x63] = [
81    0x25, 0x24, 0x27, 0x2D, 0x32, 0x3A, 0x26, 0x2E, 0x2B, 0x3B, 0x29, 0x2C, 0x28, 0x33, 0x33, 0x3C,
82    0x3D, 0x3E, 0x33, 0x22, 0x23, 0x33, 0x34, 0x34, 0x34, 0x34, 0x33, 0x3F, 0x34, 0x34, 0x34, 0x34,
83    0x34, 0x40, 0x41, 0x42, 0x43, 0x44, 0x45, 0x30, 0x31, 0x34, 0x34, 0x34, 0x34, 0x46, 0x47, 0x34,
84    0x48, 0x49, 0x21, 0x34, 0x4A, 0x39, 0x35, 0x35, 0x2F, 0x4B, 0x34, 0x34, 0x34, 0x33, 0x33, 0x33,
85    0x25, 0x24, 0x2A, 0x22, 0x2B, 0x4C, 0x33, 0x34, 0x34, 0x34, 0x33, 0x33, 0x33, 0x34, 0x34, 0x0,
86    0x33, 0x33, 0x33, 0x34, 0x34, 0x34, 0x34, 0x33, 0x4D, 0x34, 0x34, 0x33, 0x34, 0x33, 0x33, 0x34,
87    0x4E, 0x4F, 0x34,
88];
89
90/// The secondary and tertiary weights of a letter without accents.
91const COMMON: (u32, u8) = (0x20, 2);
92
93/// A collation element: the primary weight (none for an accent), and the
94/// secondary and tertiary weights.
95type Element = (Option<(u8, u32)>, u32, u8);
96
97/// The collation elements of `text` in canonical decomposition, so an
98/// accented letter is its base letter and its marks.
99fn elements(text: &str) -> Vec<Element> {
100    use unicode_normalization::UnicodeNormalization;
101    let mut elements = Vec::new();
102    for c in text.nfd() {
103        if let Ok(index) = LETTERS.binary_search_by_key(&c, |(letter, _)| *letter) {
104            elements.extend(LETTERS[index].1.iter().map(|weight| match *weight {
105                L(base, after, tertiary) => (Some(letter(base, after)), COMMON.0, tertiary),
106                A(secondary, tertiary) => (None, secondary, tertiary),
107            }));
108        } else if unicode_normalization::char::is_combining_mark(c) {
109            // Marks DUCET does not list here follow its marks by code point.
110            let secondary = (c as usize)
111                .checked_sub(0x300)
112                .and_then(|index| MARKS.get(index))
113                .map_or(0x200 + c as u32, |&weight| u32::from(weight));
114            if secondary != 0 {
115                elements.push((None, secondary, COMMON.1));
116            }
117        } else {
118            let tertiary = if c.is_uppercase() { 8 } else { COMMON.1 };
119            elements.push((Some(primary(c)), COMMON.0, tertiary));
120        }
121    }
122    elements
123}
124
125/// `a.localeCompare(b)` for identifiers and names: primary weights first,
126/// with accented letters next to their base letters, then accents, then case
127/// and ligatures. Texts ICU weighs alike, such as `đ` and `d` with a combining
128/// stroke, are equal.
129pub fn locale_compare(a: &str, b: &str) -> Ordering {
130    let (a_elements, b_elements) = (elements(a), elements(b));
131    let primaries = |elements: &[Element]| elements.iter().filter_map(|e| e.0).collect::<Vec<_>>();
132    let secondaries = |elements: &[Element]| elements.iter().map(|e| e.1).collect::<Vec<_>>();
133    let tertiaries = |elements: &[Element]| elements.iter().map(|e| e.2).collect::<Vec<_>>();
134    primaries(&a_elements)
135        .cmp(&primaries(&b_elements))
136        .then_with(|| secondaries(&a_elements).cmp(&secondaries(&b_elements)))
137        .then_with(|| tertiaries(&a_elements).cmp(&tertiaries(&b_elements)))
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143
144    #[test]
145    fn sorts_like_locale_compare() {
146        // Orders checked against Node's localeCompare over the whole catalog,
147        // and for names with accents and Latin letters such as ø, ß and þ.
148        // "e\u{301}" is the decomposed "é".
149        let mut ids = vec![
150            "gpt-4o",
151            "gpt-4.1",
152            "GPT-4",
153            "gpt-4",
154            "gpt-40",
155            "a~b",
156            "a:b",
157            "a/b",
158            "a@b",
159            "a-b",
160            "Zed.txt",
161            "ünï cödé.txt",
162            "été",
163            "Été",
164            "ete",
165            "être",
166            "côté",
167            "cote",
168            "coté",
169            "côte",
170            "e\u{301}tf",
171            "Ångström",
172            "angle",
173            "ørsted",
174            "oz",
175            "straße",
176            "strasse",
177            "stras",
178            "Łódź",
179            "lz",
180            "æble",
181            "aeble",
182            "afar",
183            "þorn",
184            "zebra",
185            "ŋa",
186            "nz",
187            "đuro",
188            "dz",
189        ];
190        ids.sort_by(|a, b| locale_compare(a, b));
191        assert_eq!(
192            ids,
193            [
194                "a-b",
195                "a:b",
196                "a@b",
197                "a/b",
198                "a~b",
199                "aeble",
200                "æble",
201                "afar",
202                "angle",
203                "Ångström",
204                "cote",
205                "coté",
206                "côte",
207                "côté",
208                "đuro",
209                "dz",
210                "ete",
211                "été",
212                "Été",
213                "e\u{301}tf",
214                "être",
215                "gpt-4",
216                "GPT-4",
217                "gpt-4.1",
218                "gpt-40",
219                "gpt-4o",
220                "Łódź",
221                "lz",
222                "nz",
223                "ŋa",
224                "ørsted",
225                "oz",
226                "stras",
227                "strasse",
228                "straße",
229                "ünï cödé.txt",
230                "zebra",
231                "Zed.txt",
232                "þorn"
233            ]
234        );
235        // Texts ICU weighs alike are equal. The table is sorted for its search.
236        assert_eq!(locale_compare("đ", "d\u{335}"), Ordering::Equal);
237        assert!(LETTERS.windows(2).all(|pair| pair[0].0 < pair[1].0));
238    }
239}