Repository navigation
Expand file tree
/
Copy pathi18n.rs
More file actions
382 lines (355 loc) · 14.3 KB
/
Copy pathi18n.rs
File metadata and controls
382 lines (355 loc) · 14.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
//! Bundled gettext localization.
//!
//! Catalogs are compiled from `assets/i18n/*.po` into Rust modules at build
//! time, so there is no libintl, no runtime PO parsing, and no network access.
//! English is both the source language and the fallback for any untranslated
//! message. Only languages with a catalog are offered.
pub use fastframe_i18n::{gettext, ngettext, pgettext};
use serde::{Deserialize, Serialize};
include!(concat!(env!("OUT_DIR"), "/catalogs.rs"));
/// The interface languages ZapFast knows about.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum Locale {
#[default]
#[serde(rename = "en")]
English,
#[serde(rename = "pt-BR")]
PortugueseBrazil,
#[serde(rename = "de")]
German,
#[serde(rename = "es")]
Spanish,
#[serde(rename = "it")]
Italian,
#[serde(rename = "fr")]
French,
#[serde(rename = "ru")]
Russian,
#[serde(rename = "zh-Hans")]
ChineseSimplified,
#[serde(rename = "tr")]
Turkish,
}
impl Locale {
/// Every locale shown in the language picker, in a stable order.
pub const ALL: [Locale; 9] = [
Self::English,
Self::PortugueseBrazil,
Self::German,
Self::Spanish,
Self::Italian,
Self::French,
Self::Russian,
Self::ChineseSimplified,
Self::Turkish,
];
/// The language's own name, for the picker.
pub fn label(self) -> &'static str {
match self {
Self::English => "English",
Self::PortugueseBrazil => "Português (Brasil)",
Self::German => "Deutsch",
Self::Spanish => "Español",
Self::Italian => "Italiano",
Self::French => "Français",
Self::Russian => "Русский",
Self::ChineseSimplified => "简体中文",
Self::Turkish => "Türkçe",
}
}
/// Maps a system language tag (BCP 47 or POSIX) to a supported locale by
/// its language subtag, so `pt-PT` and `pt_BR` both resolve to Portuguese.
/// Chinese is the exception: its script decides, so a reader who asked for
/// Traditional keeps English instead of getting the wrong characters.
pub fn from_system(identifier: &str) -> Option<Locale> {
fastframe_i18n::LanguageTag::parse(identifier).and_then(|tag| Self::from_tag(&tag))
}
fn from_tag(tag: &fastframe_i18n::LanguageTag) -> Option<Locale> {
Some(match tag.language.as_str() {
"en" => Self::English,
"pt" => Self::PortugueseBrazil,
"de" => Self::German,
"es" => Self::Spanish,
"it" => Self::Italian,
"fr" => Self::French,
"ru" => Self::Russian,
"tr" => Self::Turkish,
"zh" => match (tag.script.as_deref(), tag.region.as_deref()) {
// An explicit script decides on its own: a tag that asks for
// Simplified and names a region where Traditional is the
// default, such as `zh-Hans-HK`, is still Simplified. The
// region only speaks for the tags that name no script.
(Some("hans"), _) => Self::ChineseSimplified,
(Some("hant"), _) => return None,
// Windows writes the legacy `zh-CHT` region, macOS and Linux
// the region subtag.
(_, Some("tw" | "hk" | "mo" | "cht")) => return None,
_ => Self::ChineseSimplified,
},
_ => return None,
})
}
}
impl fastframe_i18n::Locale for Locale {
fn catalog(self) -> Option<&'static dyn fastframe_i18n::Translator> {
match self {
Self::PortugueseBrazil => Some(&pt_br::Translator),
Self::German => Some(&de::Translator),
Self::Spanish => Some(&es::Translator),
Self::French => Some(&fr::Translator),
Self::Italian => Some(&it::Translator),
Self::Russian => Some(&ru::Translator),
Self::ChineseSimplified => Some(&zh_hans::Translator),
Self::Turkish => Some(&tr::Translator),
Self::English => None,
}
}
}
/// The first supported language the operating system prefers, falling back
/// to English.
///
/// Unit tests assert the English source strings, so the machine the suite runs
/// on must not decide their outcome: a developer with a Portuguese Brazil
/// system would otherwise see failures that the continuous integration, running
/// in English, does not.
pub fn detect() -> Locale {
if cfg!(test) {
return Locale::English;
}
fastframe_i18n::detect(Locale::from_tag).unwrap_or_default()
}
/// Resolves a stored preference: an explicit choice wins, otherwise detect.
pub fn resolve(interface_language: Option<Locale>) -> Locale {
interface_language.unwrap_or_else(detect)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn system_locales_map_to_the_right_catalog() {
assert_eq!(Locale::from_system("pt-BR"), Some(Locale::PortugueseBrazil));
assert_eq!(Locale::from_system("pt_PT"), Some(Locale::PortugueseBrazil));
assert_eq!(Locale::from_system("de-DE"), Some(Locale::German));
assert_eq!(Locale::from_system("es"), Some(Locale::Spanish));
assert_eq!(Locale::from_system("it-IT"), Some(Locale::Italian));
assert_eq!(Locale::from_system("fr-FR"), Some(Locale::French));
assert_eq!(Locale::from_system("ru-RU"), Some(Locale::Russian));
assert_eq!(
Locale::from_system("zh-Hans"),
Some(Locale::ChineseSimplified)
);
assert_eq!(Locale::from_system("zh"), Some(Locale::ChineseSimplified));
assert_eq!(
Locale::from_system("zh_CN.GB2312"),
Some(Locale::ChineseSimplified)
);
// Traditional Chinese has no catalog, so it keeps English rather than
// showing Simplified characters to a reader who asked for another script.
// A script the tag names itself decides, even beside a region whose
// own default is the other one.
assert_eq!(
Locale::from_system("zh-Hans-TW"),
Some(Locale::ChineseSimplified)
);
assert_eq!(
Locale::from_system("zh-Hans-HK"),
Some(Locale::ChineseSimplified)
);
assert_eq!(
Locale::from_system("zh-Hans-CN"),
Some(Locale::ChineseSimplified)
);
assert_eq!(Locale::from_system("zh-Hant-CN"), None);
assert_eq!(Locale::from_system("zh-TW"), None);
assert_eq!(Locale::from_system("zh-Hant"), None);
assert_eq!(Locale::from_system("zh-CHT"), None);
assert_eq!(Locale::from_system("tr-TR"), Some(Locale::Turkish));
assert_eq!(Locale::from_system("tr"), Some(Locale::Turkish));
assert_eq!(Locale::from_system("en-US"), Some(Locale::English));
assert_eq!(Locale::from_system("ja-JP"), None);
assert_eq!(Locale::default(), Locale::English);
}
/// Every preferred language is tried in order, not only the first: a
/// desktop that lists an unsupported language first still gets the next.
#[test]
fn the_first_supported_preferred_language_wins() {
assert_eq!(
fastframe_i18n::first_supported(["nb-NO", "de-DE", "fr-FR"], Locale::from_tag),
Some(Locale::German)
);
assert_eq!(
Locale::from_system("pt_BR.UTF-8"),
Some(Locale::PortugueseBrazil)
);
assert_eq!(Locale::from_system("tr_TR.UTF-8"), Some(Locale::Turkish));
}
/// The suite asserts the English source strings, so the language of the
/// machine that runs it must not decide whether it passes, while an
/// explicit choice still wins over the detection.
#[test]
fn detection_is_english_under_test_and_a_choice_still_wins() {
assert_eq!(detect(), Locale::English);
assert_eq!(
resolve(Some(Locale::PortugueseBrazil)),
Locale::PortugueseBrazil
);
assert_eq!(resolve(None), Locale::English);
}
#[test]
fn unknown_keys_fall_back_to_the_english_source() {
assert_eq!(gettext(Locale::PortugueseBrazil, "Chats"), "Conversas");
assert_eq!(gettext(Locale::English, "Chats"), "Chats");
let missing = "A string nobody has translated";
assert_eq!(gettext(Locale::PortugueseBrazil, missing), missing);
assert_eq!(gettext(Locale::German, missing), missing);
}
#[test]
fn german_catalog_translates_the_pilot() {
assert_eq!(gettext(Locale::German, "Chats"), "Chats");
assert_eq!(gettext(Locale::German, "Search"), "Suchen");
assert_eq!(gettext(Locale::German, "Unread"), "Ungelesen");
assert_eq!(
gettext(Locale::German, "Type a message"),
"Nachricht eingeben"
);
assert_eq!(gettext(Locale::German, "Settings"), "Einstellungen");
assert_eq!(gettext(Locale::German, "Monday"), "Montag");
}
#[test]
fn german_plural_rules_cover_singular_and_plural() {
assert_eq!(
ngettext(Locale::German, "{} member", "{} members", 1),
"{} Mitglied"
);
assert_eq!(
ngettext(Locale::German, "{} member", "{} members", 2),
"{} Mitglieder"
);
// German treats zero as plural, unlike English.
assert_eq!(
ngettext(Locale::German, "{} member", "{} members", 0),
"{} Mitglieder"
);
}
#[test]
fn remaining_pilot_catalogs_translate() {
assert_eq!(gettext(Locale::Spanish, "Search"), "Buscar");
assert_eq!(gettext(Locale::French, "Search"), "Rechercher");
assert_eq!(gettext(Locale::Italian, "Search"), "Cerca");
assert_eq!(gettext(Locale::Russian, "Search"), "Поиск");
assert_eq!(gettext(Locale::Spanish, "Unread"), "No leídos");
assert_eq!(gettext(Locale::French, "Settings"), "Paramètres");
assert_eq!(
gettext(Locale::Italian, "Type a message"),
"Scrivi un messaggio"
);
assert_eq!(gettext(Locale::Russian, "Today"), "Сегодня");
}
#[test]
fn russian_plural_rules_select_three_forms() {
assert_eq!(
ngettext(Locale::Russian, "{} member", "{} members", 1),
"{} участник"
);
assert_eq!(
ngettext(Locale::Russian, "{} member", "{} members", 3),
"{} участника"
);
assert_eq!(
ngettext(Locale::Russian, "{} member", "{} members", 5),
"{} участников"
);
assert_eq!(
ngettext(Locale::Russian, "{} member", "{} members", 21),
"{} участник"
);
}
#[test]
fn contextual_lookups_stay_separate_from_plain_ones() {
// The pilot catalog has no msgctxt entries, so a contextual lookup is
// distinct from gettext but still falls back to the source.
assert_eq!(pgettext(Locale::PortugueseBrazil, "verb", "Chats"), "Chats");
assert_eq!(pgettext(Locale::English, "verb", "Chats"), "Chats");
assert_eq!(gettext(Locale::PortugueseBrazil, "Chats"), "Conversas");
}
#[test]
fn chinese_simplified_catalog_translates() {
assert_eq!(gettext(Locale::ChineseSimplified, "Chats"), "聊天");
assert_eq!(gettext(Locale::ChineseSimplified, "
8000
Search"), "搜索");
assert_eq!(gettext(Locale::ChineseSimplified, "Settings"), "设置");
assert_eq!(
gettext(Locale::ChineseSimplified, "Type a message"),
"输入消息"
);
assert_eq!(gettext(Locale::ChineseSimplified, "Monday"), "周一");
assert_eq!(gettext(Locale::ChineseSimplified, "Yesterday"), "昨天");
}
/// Chinese has a single plural form, so one text covers every count.
#[test]
fn chinese_plural_rules_use_one_form() {
for count in [0, 1, 2, 21] {
assert_eq!(
ngettext(Locale::ChineseSimplified, "{} member", "{} members", count),
"{} 位成员"
);
}
}
/// The same English word with two meanings stays two different strings.
#[test]
fn chinese_contexts_stay_separate_from_plain_lookups() {
assert_eq!(gettext(Locale::ChineseSimplified, "About"), "关于");
assert_eq!(
pgettext(Locale::ChineseSimplified, "privacy", "About"),
"个人简介"
);
assert_eq!(gettext(Locale::ChineseSimplified, "Groups"), "群组");
assert_eq!(pgettext(Locale::ChineseSimplified, "sound", "None"), "无");
}
#[test]
fn portuguese_plural_rules_cover_singular_and_plural() {
for (count, expected) in [(1, "{} membro"), (2, "{} membros"), (5, "{} membros")] {
assert_eq!(
ngettext(Locale::PortugueseBrazil, "{} member", "{} members", count),
expected
);
}
// Without a catalog the bare English count rule applies.
assert_eq!(
ngettext(Locale::English, "{} member", "{} members", 1),
"{} member"
);
assert_eq!(
ngettext(Locale::English, "{} member", "{} members", 2),
"{} members"
);
}
#[test]
fn turkish_catalog_translates() {
assert_eq!(gettext(Locale::Turkish, "Chats"), "Sohbetler");
assert_eq!(gettext(Locale::Turkish, "Search"), "Ara");
assert_eq!(gettext(Locale::Turkish, "Settings"), "Ayarlar");
assert_eq!(
gettext(Locale::Turkish, "Type a message"),
"Bir mesaj yazın"
);
assert_eq!(gettext(Locale::Turkish, "Monday"), "Pazartesi");
assert_eq!(gettext(Locale::Turkish, "Yesterday"), "Dün");
}
#[test]
fn turkish_plural_rules_cover_singular_and_plural() {
for (count, expected) in [(1, "{} üye"), (2, "{} üye"), (5, "{} üye")] {
assert_eq!(
ngettext(Locale::Turkish, "{} member", "{} members", count),
expected
);
}
}
#[test]
fn turkish_contexts_stay_separate_from_plain_lookups() {
assert_eq!(gettext(Locale::Turkish, "About"), "Hakkımda");
assert_eq!(pgettext(Locale::Turkish, "privacy", "About"), "Hakkımda");
assert_eq!(gettext(Locale::Turkish, "Groups"), "Gruplar");
assert_eq!(pgettext(Locale::Turkish, "sound", "None"), "Yok");
assert_eq!(gettext(Locale::Turkish, "None"), "None");
}
}