livesplit_title_abbreviations/
lib.rs

1#![no_std]
2
3extern crate alloc;
4
5use alloc::{boxed::Box, format, string::String, vec, vec::Vec};
6use unicase::UniCase;
7
8// FIXME: Use generators once those work on stable Rust.
9
10fn ends_with_roman_numeral(name: &str) -> bool {
11    name.split_whitespace().rev().next().map_or(false, |n| {
12        n.chars().all(|c| c == 'I' || c == 'V' || c == 'X')
13    })
14}
15
16fn ends_with_numeric(name: &str) -> bool {
17    name.chars().last().map_or(false, |c| c.is_numeric())
18}
19
20fn series_subtitle_handling(name: &str, split_token: &str, list: &mut Vec<Box<str>>) -> bool {
21    let mut iter = name.splitn(2, split_token);
22    if let (Some(series), Some(subtitle)) = (iter.next(), iter.next()) {
23        let series_abbreviations = abbreviate(series);
24        let subtitle_abbreviations = abbreviate(subtitle);
25        let series_trimmed = series.trim_end();
26
27        let is_series_representative =
28            ends_with_numeric(series_trimmed) || ends_with_roman_numeral(series_trimmed);
29
30        let is_there_only_one_series_abbreviation = series_abbreviations.len() == 1;
31
32        for subtitle_abbreviation in &subtitle_abbreviations {
33            for series_abbreviation in &series_abbreviations {
34                if is_series_representative
35                    || &**series_abbreviation != series
36                    || is_there_only_one_series_abbreviation
37                {
38                    list.push(
39                        format!("{series_abbreviation}{split_token}{subtitle_abbreviation}").into(),
40                    );
41                }
42            }
43        }
44
45        if is_series_representative {
46            list.extend(series_abbreviations);
47        }
48        list.extend(subtitle_abbreviations);
49
50        true
51    } else {
52        false
53    }
54}
55
56fn left_right_handling(name: &str, split_token: &str, list: &mut Vec<Box<str>>) -> bool {
57    let mut iter = name.splitn(2, split_token);
58    if let (Some(series), Some(subtitle)) = (iter.next(), iter.next()) {
59        let series_abbreviations = abbreviate(series);
60        let subtitle_abbreviations = abbreviate(subtitle);
61
62        for subtitle_abbreviation in &subtitle_abbreviations {
63            for series_abbreviation in &series_abbreviations {
64                list.push(
65                    format!("{series_abbreviation}{split_token}{subtitle_abbreviation}").into(),
66                );
67            }
68        }
69
70        true
71    } else {
72        false
73    }
74}
75
76fn and_handling(name: &str, list: &mut Vec<Box<str>>) -> bool {
77    let and = UniCase::new("and");
78    for word in name.split_whitespace() {
79        if UniCase::new(word) == and {
80            let index = word.as_ptr() as usize - name.as_ptr() as usize;
81            let (left, rest) = name.split_at(index);
82            let right = &rest[word.len()..];
83            let name = format!("{left}&{right}");
84            list.extend(abbreviate(&name));
85            return true;
86        }
87    }
88    false
89}
90
91fn remove_prefix_word<'a>(text: &'a str, word: &str) -> Option<&'a str> {
92    let first_word = text.split_whitespace().next()?;
93    if unicase::eq(first_word, word) {
94        Some(text[first_word.len()..].trim_start())
95    } else {
96        None
97    }
98}
99
100fn is_all_caps_or_digits(text: &str) -> bool {
101    text.chars().all(|c| c.is_uppercase() || c.is_numeric())
102}
103
104pub fn abbreviate(name: &str) -> Vec<Box<str>> {
105    let name = name.trim();
106    let mut list = vec![];
107    if name.is_empty() {
108        return list;
109    }
110
111    let parenthesis = name
112        .char_indices()
113        .rev()
114        .find(|&(_, c)| c == '(')
115        .and_then(|(start, _)| {
116            name[start + 1..]
117                .char_indices()
118                .find(|&(_, c)| c == ')')
119                .map(|(end, _)| (start, end + 2))
120        });
121
122    if let Some((start, end)) = parenthesis {
123        let (before_parenthesis, rest) = name.split_at(start);
124        let after_parenthesis = &rest[end..];
125        let name = format!(
126            "{} {}",
127            before_parenthesis.trim_end(),
128            after_parenthesis.trim_start()
129        );
130        list.extend(abbreviate(&name));
131    } else if series_subtitle_handling(name, ": ", &mut list)
132        || series_subtitle_handling(name, " - ", &mut list)
133        || left_right_handling(name, " | ", &mut list)
134        || and_handling(name, &mut list)
135    {
136    } else {
137        if let Some(rest) =
138            remove_prefix_word(name, "the").or_else(|| remove_prefix_word(name, "a"))
139        {
140            list.push(rest.into());
141        }
142
143        if name.contains(char::is_whitespace) {
144            let mut abbreviated = String::new();
145            for word in name.split(|c: char| c.is_whitespace() || c == '-') {
146                if let Some(first_char) = word.chars().next() {
147                    let word_mapped = word.chars().map(|c| if c == '&' { 'a' } else { c });
148
149                    if first_char.is_numeric() {
150                        abbreviated.extend(word_mapped);
151                    } else if word.len() <= 4 && is_all_caps_or_digits(word) {
152                        if !abbreviated.is_empty() {
153                            abbreviated.push(' ');
154                        }
155                        abbreviated.extend(word_mapped);
156                    } else {
157                        abbreviated.push(first_char);
158                    }
159                }
160            }
161            list.push(abbreviated.into());
162        }
163    }
164
165    list.sort_unstable();
166    list.dedup();
167
168    if let Some(idx) = list.iter().position(|x| name == x.as_ref()) {
169        let last = list.len() - 1;
170        list.swap(idx, last);
171    } else {
172        list.push(name.into());
173    }
174
175    list
176}
177
178pub fn abbreviate_category(category: &str) -> Vec<Box<str>> {
179    let mut abbrevs = Vec::new();
180
181    let mut splits = category.splitn(2, '(');
182    let before = splits.next().unwrap().trim();
183
184    if let Some(rest) = splits.next() {
185        splits = rest.splitn(2, ')');
186        let inside = splits.next().unwrap();
187        if let Some(after) = splits.next() {
188            let after = after.trim_end();
189
190            let mut buf = String::with_capacity(category.len());
191            buf.push_str(before);
192            buf.push_str(" (");
193
194            let mut splits = inside.split(',');
195            let mut variable = splits.next().unwrap();
196            for next_variable in splits {
197                buf.push_str(variable);
198                let old_len = buf.len();
199
200                buf.push(')');
201                buf.push_str(after);
202                abbrevs.push(buf.as_str().into());
203
204                buf.drain(old_len..);
205                buf.push(',');
206                variable = next_variable;
207            }
208
209            if after.trim().is_empty() {
210                buf.drain(before.len()..);
211            } else {
212                buf.drain(before.len() + 1..);
213                buf.push_str(after);
214            }
215
216            abbrevs.push(buf.into());
217        }
218    }
219
220    abbrevs.push(category.into());
221
222    abbrevs
223}
224
225#[cfg(test)]
226mod tests {
227    use super::abbreviate;
228    use alloc::boxed::Box;
229    use alloc::vec;
230
231    // The tests using actual game titles can be thrown out or edited if any
232    // major changes need to be made to the abbreviation algorithm. Do not
233    // hesitate to remove them if they are getting in the way of actual
234    // improvements.
235    //
236    // They exist purely as another measure to prevent accidental breakage.
237    #[test]
238    fn game_test_1() {
239        let abbreviations = abbreviate("Burnout 3: Takedown");
240
241        let expected = vec![
242            Box::from("B3"),
243            Box::from("B3: Takedown"),
244            Box::from("Burnout 3"),
245            Box::from("Takedown"),
246            Box::from("Burnout 3: Takedown"),
247        ];
248
249        assert_eq!(abbreviations, expected);
250    }
251
252    #[test]
253    fn game_test_2() {
254        let abbreviations = abbreviate("The Legend of Zelda: The Wind Waker");
255
256        let expected = vec![
257            Box::from("Legend of Zelda: TWW"),
258            Box::from("Legend of Zelda: The Wind Waker"),
259            Box::from("Legend of Zelda: Wind Waker"),
260            Box::from("TLoZ: TWW"),
261            Box::from("TLoZ: The Wind Waker"),
262            Box::from("TLoZ: Wind Waker"),
263            Box::from("TWW"),
264            Box::from("The Wind Waker"),
265            Box::from("Wind Waker"),
266            Box::from("The Legend of Zelda: The Wind Waker"),
267        ];
268
269        assert_eq!(abbreviations, expected);
270    }
271
272    #[test]
273    fn game_test_3() {
274        let abbreviations = abbreviate("SpongeBob SquarePants: Battle for Bikini Bottom");
275
276        let expected = vec![
277            Box::from("Battle for Bikini Bottom"),
278            Box::from("BfBB"),
279            Box::from("SS: Battle for Bikini Bottom"),
280            Box::from("SS: BfBB"),
281            Box::from("SpongeBob SquarePants: Battle for Bikini Bottom"),
282        ];
283
284        assert_eq!(abbreviations, expected);
285    }
286
287    #[test]
288    #[rustfmt::skip]
289    fn game_test_4() {
290        let abbreviations = abbreviate("Super Mario 64");
291
292        let expected = vec![
293            Box::from("SM64"),
294            Box::from("Super Mario 64"),
295        ];
296
297        assert_eq!(abbreviations, expected);
298    }
299
300    #[test]
301    fn contains_original_title() {
302        let abbreviations = abbreviate("test title: the game");
303        assert!(abbreviations.contains(&Box::from("test title: the game")));
304    }
305
306    #[test]
307    fn removes_parens() {
308        let abbreviations = abbreviate("test title (the game)");
309        assert!(abbreviations.contains(&Box::from("test title")));
310    }
311
312    #[test]
313    fn original_title_is_last() {
314        let abbreviations = abbreviate("test title: the game");
315        let last = abbreviations.last().unwrap();
316
317        assert_eq!("test title: the game", last.as_ref())
318    }
319}