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