Coverage Report

Created: 2026-11-04 02:14

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
super-tts-app/src/ui/languages.rs
Line
Count
Source
1
// SPDX-License-Identifier: GPL-3.0-only
2
//! Human-friendly names for BCP-47 language tags.
3
//!
4
//! Display only. The wire form is always the tag itself, and *which* tags exist
5
//! is never decided here: both pickers list what the daemon says they may offer
6
//! — the global setting's own vocabulary, and a model's own tags — because that
7
//! set changes as backends are installed and removed, and which of `en` and
8
//! `en-US` a model answers to is a rule only the daemon's resolver knows. A
9
//! curated table once filled the global picker and is gone for exactly that
10
//! reason: it offered tags no installed model served, and silently never
11
//! learned the ones a new backend brought.
12
//!
13
//! Names are composed at render time from a base-language table, a script table
14
//! and a region table, so any tag either endpoint returns renders — including
15
//! ones no table anticipated, which keep their raw subtags rather than
16
//! disappearing.
17
18
/// Script subtag (ISO 15924) → display name. Names the `Hans` / `Hant` Chinese
19
/// distinction (a script, not a region) and the Latin / Cyrillic / Arabic
20
/// variants some languages carry. Unrecognized scripts keep their raw subtag.
21
const SCRIPTS: &[(&str, &str)] = &[
22
    ("Hans", "Simplified"),
23
    ("Hant", "Traditional"),
24
    ("Latn", "Latin"),
25
    ("Cyrl", "Cyrillic"),
26
    ("Arab", "Arabic"),
27
    ("Deva", "Devanagari"),
28
    ("Mong", "Mongolian"),
29
];
30
31
/// Base language subtag (ISO 639-1, plus a few ISO 639-3) → English name.
32
const BASE_LANGUAGES: &[(&str, &str)] = &[
33
    ("af", "Afrikaans"),
34
    ("am", "Amharic"),
35
    ("ar", "Arabic"),
36
    ("as", "Assamese"),
37
    ("az", "Azerbaijani"),
38
    ("ba", "Bashkir"),
39
    ("be", "Belarusian"),
40
    ("bg", "Bulgarian"),
41
    ("bn", "Bengali"),
42
    ("bo", "Tibetan"),
43
    ("br", "Breton"),
44
    ("bs", "Bosnian"),
45
    ("ca", "Catalan"),
46
    ("cs", "Czech"),
47
    ("cy", "Welsh"),
48
    ("da", "Danish"),
49
    ("de", "German"),
50
    ("el", "Greek"),
51
    ("en", "English"),
52
    ("es", "Spanish"),
53
    ("et", "Estonian"),
54
    ("eu", "Basque"),
55
    ("fa", "Persian"),
56
    ("fi", "Finnish"),
57
    ("fo", "Faroese"),
58
    ("fr", "French"),
59
    ("gl", "Galician"),
60
    ("gu", "Gujarati"),
61
    ("ha", "Hausa"),
62
    ("haw", "Hawaiian"),
63
    ("he", "Hebrew"),
64
    ("hi", "Hindi"),
65
    ("hr", "Croatian"),
66
    ("ht", "Haitian Creole"),
67
    ("hu", "Hungarian"),
68
    ("hy", "Armenian"),
69
    ("id", "Indonesian"),
70
    ("is", "Icelandic"),
71
    ("it", "Italian"),
72
    ("ja", "Japanese"),
73
    ("jw", "Javanese"),
74
    ("ka", "Georgian"),
75
    ("kk", "Kazakh"),
76
    ("km", "Khmer"),
77
    ("kn", "Kannada"),
78
    ("ko", "Korean"),
79
    ("la", "Latin"),
80
    ("lb", "Luxembourgish"),
81
    ("ln", "Lingala"),
82
    ("lo", "Lao"),
83
    ("lt", "Lithuanian"),
84
    ("lv", "Latvian"),
85
    ("mg", "Malagasy"),
86
    ("mi", "Maori"),
87
    ("mk", "Macedonian"),
88
    ("ml", "Malayalam"),
89
    ("mn", "Mongolian"),
90
    ("mr", "Marathi"),
91
    ("ms", "Malay"),
92
    ("mt", "Maltese"),
93
    ("my", "Burmese"),
94
    ("ne", "Nepali"),
95
    ("nl", "Dutch"),
96
    ("nn", "Norwegian Nynorsk"),
97
    ("no", "Norwegian"),
98
    ("oc", "Occitan"),
99
    ("pa", "Punjabi"),
100
    ("pl", "Polish"),
101
    ("ps", "Pashto"),
102
    ("pt", "Portuguese"),
103
    ("ro", "Romanian"),
104
    ("ru", "Russian"),
105
    ("sa", "Sanskrit"),
106
    ("sd", "Sindhi"),
107
    ("si", "Sinhala"),
108
    ("sk", "Slovak"),
109
    ("sl", "Slovenian"),
110
    ("sn", "Shona"),
111
    ("so", "Somali"),
112
    ("sq", "Albanian"),
113
    ("sr", "Serbian"),
114
    ("su", "Sundanese"),
115
    ("sv", "Swedish"),
116
    ("sw", "Swahili"),
117
    ("ta", "Tamil"),
118
    ("te", "Telugu"),
119
    ("tg", "Tajik"),
120
    ("th", "Thai"),
121
    ("tk", "Turkmen"),
122
    ("tl", "Tagalog"),
123
    ("tr", "Turkish"),
124
    ("tt", "Tatar"),
125
    ("uk", "Ukrainian"),
126
    ("ur", "Urdu"),
127
    ("uz", "Uzbek"),
128
    ("vi", "Vietnamese"),
129
    ("yi", "Yiddish"),
130
    ("yo", "Yoruba"),
131
    ("yue", "Cantonese"),
132
    ("zh", "Chinese"),
133
];
134
135
/// Region subtag → English name: ISO 3166-1 alpha-2 country codes and UN M.49
136
/// area codes (`419` = Latin America).
137
const REGIONS: &[(&str, &str)] = &[
138
    ("AE", "United Arab Emirates"),
139
    ("AR", "Argentina"),
140
    ("AT", "Austria"),
141
    ("AU", "Australia"),
142
    ("BD", "Bangladesh"),
143
    ("BE", "Belgium"),
144
    ("BG", "Bulgaria"),
145
    ("BO", "Bolivia"),
146
    ("BR", "Brazil"),
147
    ("BY", "Belarus"),
148
    ("CA", "Canada"),
149
    ("CH", "Switzerland"),
150
    ("CL", "Chile"),
151
    ("CN", "China"),
152
    ("CO", "Colombia"),
153
    ("CR", "Costa Rica"),
154
    ("CU", "Cuba"),
155
    ("CY", "Cyprus"),
156
    ("CZ", "Czechia"),
157
    ("DE", "Germany"),
158
    ("DK", "Denmark"),
159
    ("DO", "Dominican Republic"),
160
    ("EC", "Ecuador"),
161
    ("EE", "Estonia"),
162
    ("EG", "Egypt"),
163
    ("ES", "Spain"),
164
    ("FI", "Finland"),
165
    ("FR", "France"),
166
    ("GB", "United Kingdom"),
167
    ("GE", "Georgia"),
168
    ("GR", "Greece"),
169
    ("GT", "Guatemala"),
170
    ("HK", "Hong Kong"),
171
    ("HR", "Croatia"),
172
    ("HU", "Hungary"),
173
    ("ID", "Indonesia"),
174
    ("IE", "Ireland"),
175
    ("IL", "Israel"),
176
    ("IN", "India"),
177
    ("IQ", "Iraq"),
178
    ("IR", "Iran"),
179
    ("IS", "Iceland"),
180
    ("IT", "Italy"),
181
    ("JP", "Japan"),
182
    ("KE", "Kenya"),
183
    ("KR", "South Korea"),
184
    ("KZ", "Kazakhstan"),
185
    ("LK", "Sri Lanka"),
186
    ("LT", "Lithuania"),
187
    ("LU", "Luxembourg"),
188
    ("LV", "Latvia"),
189
    ("MA", "Morocco"),
190
    ("MX", "Mexico"),
191
    ("MY", "Malaysia"),
192
    ("NG", "Nigeria"),
193
    ("NL", "Netherlands"),
194
    ("NO", "Norway"),
195
    ("NP", "Nepal"),
196
    ("NZ", "New Zealand"),
197
    ("PA", "Panama"),
198
    ("PE", "Peru"),
199
    ("PH", "Philippines"),
200
    ("PK", "Pakistan"),
201
    ("PL", "Poland"),
202
    ("PT", "Portugal"),
203
    ("PY", "Paraguay"),
204
    ("RO", "Romania"),
205
    ("RS", "Serbia"),
206
    ("RU", "Russia"),
207
    ("SA", "Saudi Arabia"),
208
    ("SE", "Sweden"),
209
    ("SG", "Singapore"),
210
    ("SI", "Slovenia"),
211
    ("SK", "Slovakia"),
212
    ("SV", "El Salvador"),
213
    ("TH", "Thailand"),
214
    ("TR", "Turkey"),
215
    ("TW", "Taiwan"),
216
    ("UA", "Ukraine"),
217
    ("US", "United States"),
218
    ("UY", "Uruguay"),
219
    ("VE", "Venezuela"),
220
    ("VN", "Vietnam"),
221
    ("ZA", "South Africa"),
222
    ("419", "Latin America"),
223
];
224
225
/// Case-insensitive lookup in a `(code, name)` table.
226
31
fn lookup(table: &[(&'static str, &'static str)], key: &str) -> Option<&'static str> {
227
31
    table
228
31
        .iter()
229
1.63k
        .
find31
(|&&(code, _)| code.eq_ignore_ascii_case(key))
230
31
        .map(|&(_, name)| name)
231
31
}
232
233
/// Whether a BCP-47 subtag has script shape: 4 ASCII letters (e.g. `Hans`).
234
15
fn is_script(sub: &str) -> bool {
235
24
    
sub.len() == 415
&&
sub.chars()6
.
all6
(|c| c.is_ascii_alphabetic())
236
15
}
237
238
/// Whether a BCP-47 subtag has region shape: a 2-letter country code or a
239
/// 3-digit UN M.49 area code (e.g. `US`, `419`).
240
9
fn is_region(sub: &str) -> bool {
241
14
    (
sub.len() == 29
&&
sub.chars()7
.
all7
(|c| c.is_ascii_alphabetic()))
242
4
        || (
sub.len() == 32
&&
sub.chars()2
.
all2
(|c| c.is_ascii_digit()))
243
9
}
244
245
/// Friendly display name for any BCP-47 tag.
246
///
247
/// `auto` becomes "Auto-detect". Otherwise the tag is split into subtags, each
248
/// classified by shape: the first is the language, a 4-letter subtag is a
249
/// script, and a 2-letter or 3-digit subtag is a region. Recognized subtags
250
/// render via their table; unrecognized ones keep their raw code (regions
251
/// upper-cased). The result is "Language (qualifier, …)" — so `en-US` renders
252
/// "English (United States)", `zh-Hans` renders "Chinese (Simplified)",
253
/// `zh-Hans-CN` renders "Chinese (Simplified, China)", `en-XYZ` renders
254
/// "English (XYZ)", and a bare unknown `xx` renders "xx".
255
#[must_use]
256
18
pub fn friendly_name(tag: &str) -> String {
257
18
    if tag.eq_ignore_ascii_case("auto") {
258
1
        return "Auto-detect".to_string();
259
17
    }
260
17
    let mut subtags = tag.split('-');
261
17
    let lang = subtags.next().unwrap_or(tag);
262
17
    let name = match lookup(BASE_LANGUAGES, lang) {
263
16
        Some(n) => n.to_string(),
264
1
        None => lang.to_string(),
265
    };
266
17
    let mut qualifiers: Vec<String> = Vec::new();
267
17
    for sub in subtags {
268
17
        if sub.is_empty() {
269
2
            continue; // tolerate a trailing/double hyphen without empty parens
270
15
        }
271
15
        if is_script(sub) {
272
6
            qualifiers.push(match lookup(SCRIPTS, sub) {
273
6
                Some(n) => n.to_string(),
274
0
                None => sub.to_string(),
275
            });
276
9
        } else if is_region(sub) {
277
8
            qualifiers.push(match lookup(REGIONS, sub) {
278
8
                Some(n) => n.to_string(),
279
0
                None => sub.to_uppercase(),
280
            });
281
1
        } else {
282
1
            qualifiers.push(sub.to_string());
283
1
        }
284
    }
285
17
    if qualifiers.is_empty() {
286
4
        name
287
    } else {
288
13
        format!("{name} ({})", qualifiers.join(", "))
289
    }
290
18
}
291
292
#[cfg(test)]
293
mod tests {
294
    use super::friendly_name;
295
296
    #[test]
297
1
    fn auto_and_bare_language() {
298
1
        assert_eq!(friendly_name("auto"), "Auto-detect");
299
1
        assert_eq!(friendly_name("en"), "English");
300
1
        assert_eq!(friendly_name("zh"), "Chinese");
301
1
    }
302
303
    #[test]
304
1
    fn language_region() {
305
1
        assert_eq!(friendly_name("en-US"), "English (United States)");
306
1
        assert_eq!(friendly_name("es-419"), "Spanish (Latin America)");
307
1
        assert_eq!(friendly_name("zh-CN"), "Chinese (China)");
308
1
        assert_eq!(friendly_name("zh-TW"), "Chinese (Taiwan)");
309
1
    }
310
311
    #[test]
312
1
    fn language_script() {
313
1
        assert_eq!(friendly_name("zh-Hans"), "Chinese (Simplified)");
314
1
        assert_eq!(friendly_name("zh-Hant"), "Chinese (Traditional)");
315
1
        assert_eq!(friendly_name("sr-Latn"), "Serbian (Latin)");
316
1
    }
317
318
    #[test]
319
1
    fn language_script_region() {
320
        // Three subtags (two hyphens) — the script must not be mistaken for the
321
        // region, and both qualifiers appear.
322
1
        assert_eq!(friendly_name("zh-Hans-CN"), "Chinese (Simplified, China)");
323
1
        assert_eq!(friendly_name("zh-Hant-TW"), "Chinese (Traditional, Taiwan)");
324
1
    }
325
326
    #[test]
327
1
    fn case_insensitive() {
328
1
        assert_eq!(friendly_name("EN-us"), "English (United States)");
329
1
        assert_eq!(friendly_name("zh-hans"), "Chinese (Simplified)");
330
1
    }
331
332
    #[test]
333
1
    fn unknown_subtags_kept_raw() {
334
1
        assert_eq!(friendly_name("en-XYZ"), "English (XYZ)");
335
1
        assert_eq!(friendly_name("xx"), "xx");
336
1
    }
337
338
    #[test]
339
1
    fn empty_subtags_dropped() {
340
1
        assert_eq!(friendly_name("en-"), "English");
341
1
        assert_eq!(friendly_name("en--US"), "English (United States)");
342
1
    }
343
}