1use fluent_bundle::concurrent::FluentBundle;
17use fluent_bundle::{FluentArgs, FluentResource, FluentValue};
18use std::fmt;
19use std::sync::OnceLock;
20use unic_langid::LanguageIdentifier;
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
24pub enum Language {
25 #[default]
26 English,
27 German,
28 French,
29 Spanish,
30 Italian,
31}
32
33impl Language {
34 pub const ALL: [Language; 5] = [
36 Language::English,
37 Language::German,
38 Language::French,
39 Language::Spanish,
40 Language::Italian,
41 ];
42
43 pub fn tag(self) -> &'static str {
45 match self {
46 Language::English => "en",
47 Language::German => "de",
48 Language::French => "fr",
49 Language::Spanish => "es",
50 Language::Italian => "it",
51 }
52 }
53
54 pub fn name(self) -> &'static str {
56 match self {
57 Language::English => "English",
58 Language::German => "Deutsch",
59 Language::French => "Français",
60 Language::Spanish => "Español",
61 Language::Italian => "Italiano",
62 }
63 }
64
65 pub fn from_tag(tag: &str) -> Option<Self> {
69 let primary = tag.split(['-', '_', '.', '@']).next()?;
70 Self::ALL
71 .into_iter()
72 .find(|language| language.tag().eq_ignore_ascii_case(primary))
73 }
74
75 fn source(self) -> &'static str {
76 match self {
77 Language::English => include_str!("../locales/en.ftl"),
78 Language::German => include_str!("../locales/de.ftl"),
79 Language::French => include_str!("../locales/fr.ftl"),
80 Language::Spanish => include_str!("../locales/es.ftl"),
81 Language::Italian => include_str!("../locales/it.ftl"),
82 }
83 }
84
85 fn bundle(self) -> &'static FluentBundle<FluentResource> {
87 static BUNDLES: [OnceLock<FluentBundle<FluentResource>>; 5] =
88 [const { OnceLock::new() }; 5];
89 let index = Self::ALL.iter().position(|l| *l == self).unwrap_or(0);
90 BUNDLES[index].get_or_init(|| {
91 let tag: LanguageIdentifier = self.tag().parse().expect("valid language tag");
92 let mut bundle = FluentBundle::new_concurrent(vec![tag]);
93 bundle.set_use_isolating(false);
96 let resource = FluentResource::try_new(self.source().to_string())
97 .unwrap_or_else(|(resource, _)| resource);
98 let _ = bundle.add_resource(resource);
99 bundle
100 })
101 }
102
103 fn format(self, id: &str, args: Option<&FluentArgs>) -> Option<String> {
105 let bundle = self.bundle();
106 let pattern = bundle.get_message(id)?.value()?;
107 let mut errors = Vec::new();
108 Some(
109 bundle
110 .format_pattern(pattern, args, &mut errors)
111 .into_owned(),
112 )
113 }
114}
115
116impl fmt::Display for Language {
117 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
118 f.write_str(self.name())
119 }
120}
121
122pub(crate) fn text(language: Language, id: &str) -> String {
124 text_with(language, id, &[])
125}
126
127pub(crate) fn text_with(
129 language: Language,
130 id: &str,
131 args: &[(&'static str, FluentValue<'_>)],
132) -> String {
133 let args = (!args.is_empty()).then(|| {
134 let mut fluent = FluentArgs::with_capacity(args.len());
135 for (name, value) in args {
136 fluent.set(*name, value.clone());
137 }
138 fluent
139 });
140 language
141 .format(id, args.as_ref())
142 .or_else(|| Language::English.format(id, args.as_ref()))
143 .unwrap_or_else(|| id.to_string())
144}
145
146#[cfg(test)]
147mod tests {
148 use super::*;
149 use fluent_syntax::ast;
150
151 fn message_ids(language: Language) -> Vec<String> {
152 let resource = FluentResource::try_new(language.source().to_string())
153 .unwrap_or_else(|(_, errors)| panic!("{language:?}: {errors:?}"));
154 resource
155 .entries()
156 .filter_map(|entry| match entry {
157 ast::Entry::Message(message) => Some(message.id.name.to_string()),
158 _ => None,
159 })
160 .collect()
161 }
162
163 #[test]
164 fn translations_are_complete() {
165 let mut english = message_ids(Language::English);
166 english.sort();
167 for language in Language::ALL {
168 let mut ids = message_ids(language);
169 ids.sort();
170 assert_eq!(ids, english, "messages of {language:?}");
171 }
172 }
173
174 #[test]
175 fn messages_format_without_errors() {
176 let mut args = FluentArgs::new();
177 for name in ["current", "total", "count", "checked", "size", "max"] {
178 args.set(name, 2);
179 }
180 args.set("mime", "image/x-test");
181 args.set("error", "broken");
182 for language in Language::ALL {
183 let bundle = language.bundle();
184 for id in message_ids(language) {
185 let pattern = bundle.get_message(&id).and_then(|m| m.value());
186 let pattern = pattern.unwrap_or_else(|| panic!("{language:?}: {id}"));
187 let mut errors = Vec::new();
188 let text = bundle.format_pattern(pattern, Some(&args), &mut errors);
189 assert!(errors.is_empty(), "{language:?} {id}: {errors:?}");
190 assert!(!text.contains(['\u{2068}', '\u{2069}']));
191 }
192 }
193 }
194
195 #[test]
196 fn plurals() {
197 let words = |language, count: usize| {
198 text_with(language, "status-words", &[("count", count.into())])
199 };
200 assert_eq!(words(Language::English, 1), "1 word");
201 assert_eq!(words(Language::English, 3), "3 words");
202 assert_eq!(words(Language::German, 1), "1 Wort");
203 assert_eq!(words(Language::German, 0), "0 Wörter");
204 assert_eq!(words(Language::French, 0), "0 mot");
205 assert_eq!(words(Language::Spanish, 2), "2 palabras");
206 assert_eq!(words(Language::Italian, 2), "2 parole");
207 }
208
209 #[test]
210 fn tags() {
211 assert_eq!(Language::from_tag("de-CH"), Some(Language::German));
212 assert_eq!(Language::from_tag("fr_FR.UTF-8"), Some(Language::French));
213 assert_eq!(Language::from_tag("ES"), Some(Language::Spanish));
214 assert_eq!(Language::from_tag("ja-JP"), None);
215 for language in Language::ALL {
216 assert_eq!(Language::from_tag(language.tag()), Some(language));
217 }
218 }
219
220 #[test]
221 fn unknown_messages_fall_back_to_the_id() {
222 assert_eq!(text(Language::German, "no-such-message"), "no-such-message");
223 }
224}