rialight_intl/
ftl.rs

1//! Module for managing Fluent Translation List (FTL).
2//!
3//! # FTL Syntax
4//!
5//! [See the FTL syntax guide.](https://projectfluent.org/fluent/guide/)
6
7pub use fluent::FluentArgs as Arguments;
8
9use icu::locid::Locale;
10use std::{
11    cell::{Cell}, collections::{HashMap, HashSet}, sync::{Arc, RwLock},
12};
13use rialight_util::{hashmap, hashset};
14
15/// Creates an `Arguments` object from a list of key-value pairs.
16///
17/// ## Example
18///
19/// ```
20/// use rialight::intl;
21///
22/// let a = intl::ftl::arguments!{
23///     "a" => "foo",
24///     "b" => "bar",
25/// };
26/// ```
27pub macro arguments {
28    ($($key:expr => $value:expr,)+) => {
29        {
30            #[allow(unused_mut)]
31            let mut r_map = ::fluent::FluentArgs::new();
32            $(
33                let _ = r_map.set($key.to_string(), Box::new($value));
34            )*
35            r_map
36        }
37    },
38    ($($key:expr => $value:expr),*) => {
39        {
40            #[allow(unused_mut)]
41            let mut r_map = ::fluent::FluentArgs::new();
42            $(
43                let _ = r_map.set($key.to_string(), Box::new($value));
44            )*
45            r_map
46        }
47    }
48}
49
50/// Interface for working with Fluent Translation Lists.
51pub struct Ftl {
52    m_current_locale: RwLock<Option<Locale>>,
53    /// Maps a Locale object to its equivalent path component.
54    /// The string to which the Locale maps depends in how the
55    /// Ftl object was constructed. If the `supported_locales` option
56    /// contains "en-us", then `m_locale_to_path_components.get(&locale!("en-US"))` returns "en-us".
57    /// When FTLs are loaded, this component is appended to the URL or file path;
58    /// for example, `"res/lang/en-us"`.
59    m_locale_to_path_components: Arc<HashMap<Locale, String>>,
60    m_supported_locales: Arc<HashSet<Locale>>,
61    m_default_locale: Locale,
62    m_fallbacks: Arc<HashMap<Locale, Vec<Locale>>>,
63    m_locale_initializers: Arc<RwLock<Vec<fn(Locale, Arc<fluent::FluentBundle<fluent::FluentResource>>)>>>,
64    m_assets: Arc<RwLock<HashMap<Locale, Arc<fluent::FluentBundle<fluent::FluentResource>>>>>,
65    m_assets_source: String,
66    m_assets_files: Vec<String>,
67    m_assets_clean_unused: bool,
68    m_assets_load_method: FtlLoadMethod,
69}
70
71fn parse_locale_or_panic(s: &str) -> Locale {
72    Locale::try_from_bytes(s.as_bytes()).expect((format!("{} is a malformed locale.", s)).as_ref())
73}
74
75fn locale_to_unic_langid_impl_langid(locale: &Locale) -> unic_langid_impl::LanguageIdentifier {
76    unic_langid_impl::LanguageIdentifier::from_bytes(locale.id.to_string().as_bytes()).unwrap()
77}
78
79fn add_ftl_bundle_resource(file_name: String, source: String, bundle: &mut fluent::FluentBundle<fluent::FluentResource>) -> bool {
80    match fluent::FluentResource::try_new(source) {
81        Ok(res) => {
82            if let Err(error_list) = bundle.add_resource(res) {
83                for e in error_list {
84                    println!("Error at {}.ftl: {}", file_name, e.to_string());
85                }
86                return false;
87            }
88        },
89        Err((_, error_list)) => {
90            for e in error_list {
91                println!("Syntax error at {}.ftl: {}", file_name, e);
92            }
93            return false;
94        },
95    }
96    true
97}
98
99impl Ftl {
100    /// Constructs a `Ftl` object.
101    pub fn new(options: &mut FtlOptions) -> Self {
102        let mut locale_to_path_components = HashMap::<Locale, String>::new();
103        let mut supported_locales = HashSet::<Locale>::new();
104        for unparsed_locale in options.m_supported_locales.get_mut().unwrap().iter() {
105            let parsed_locale = parse_locale_or_panic(unparsed_locale);
106            locale_to_path_components.insert(parsed_locale.clone(), unparsed_locale.clone());
107            supported_locales.insert(parsed_locale);
108        }
109        let mut fallbacks = HashMap::<Locale, Vec<Locale>>::new();
110        for (k, v) in options.m_fallbacks.get_mut().unwrap().iter() {
111            fallbacks.insert(parse_locale_or_panic(k), v.iter().map(|s| parse_locale_or_panic(s)).collect());
112        }
113        let default_locale = options.m_default_locale.get_mut().unwrap().clone();
114        Self {
115            m_current_locale: RwLock::new(None),
116            m_locale_to_path_components: Arc::new(locale_to_path_components),
117            m_supported_locales: Arc::new(supported_locales),
118            m_default_locale: parse_locale_or_panic(&default_locale),
119            m_fallbacks: Arc::new(fallbacks),
120            m_locale_initializers: Arc::new(RwLock::new(vec![])),
121            m_assets: Arc::new(RwLock::new(HashMap::new())),
122            m_assets_source: options.m_assets.get_mut().unwrap().m_source.get_mut().unwrap().clone(),
123            m_assets_files: options.m_assets.get_mut().unwrap().m_files.get_mut().unwrap().iter().map(|s| s.clone()).collect(),
124            m_assets_clean_unused: options.m_assets.get_mut().unwrap().m_clean_unused.get(),
125            m_assets_load_method: options.m_assets.get_mut().unwrap().m_load_method.get(),
126        }
127    }
128
129    /// Returns a set of supported locales, reflecting
130    /// the ones that were specified when constructing the `Ftl` object.
131    pub fn supported_locales(&self) -> HashSet<Locale> {
132        self.m_supported_locales.as_ref().clone()
133    }
134
135    /// Returns `true` if the locale is one of the supported locales
136    /// that were specified when constructing the `Ftl` object,
137    /// otherwise `false`.
138    pub fn supports_locale(&self, arg: &Locale) -> bool {
139        self.m_supported_locales.contains(arg)
140    }
141
142    /// Returns the currently loaded locale.
143    pub fn current_locale(&self) -> Option<Locale> {
144        self.m_current_locale.read().unwrap().clone()
145    }
146
147    /// Returns the currently loaded locale followed by its fallbacks or empty if no locale is loaded.
148    pub fn locale_and_fallbacks(&self) -> HashSet<Locale> {
149        if let Some(c) = self.current_locale() {
150            let mut r: HashSet<Locale> = hashset![c.clone()];
151            self.enumerate_fallbacks(c.clone(), &mut r);
152            return r;
153        }
154        hashset![]
155    }
156
157    /// Returns the currently loaded fallbacks.
158    pub fn fallbacks(&self) -> HashSet<Locale> {
159        if let Some(c) = self.current_locale() {
160            let mut r: HashSet<Locale> = hashset![];
161            self.enumerate_fallbacks(c.clone(), &mut r);
162            return r;
163        }
164        hashset![]
165    }
166
167    /// Adds a callback function to initialize the `FluentBundle` object of a locale.
168    /// The callback is called when the locale is loaded.
169    pub fn initialize_locale(&self, callback: fn(Locale, Arc<fluent::FluentBundle<fluent::FluentResource>>)) {
170        self.m_locale_initializers.write().unwrap().push(callback);
171    }
172
173    /// Attempts to load a locale and its fallbacks.
174    /// If the locale argument is specified, it is loaded.
175    /// Otherwise, if there is a default locale, it is loaded, and if not,
176    /// the method panics.
177    ///
178    /// If any resource fails to load, the method returns `false`, otherwise `true`.
179    pub async fn load(&self, mut new_locale: Option<Locale>) -> bool {
180        if new_locale.is_none() {
181            new_locale = Some(self.m_default_locale.clone());
182        }
183        let new_locale = new_locale.unwrap();
184        if !self.supports_locale(&new_locale) {
185            panic!("Unsupported locale: {}", new_locale);
186        }
187        let mut to_load: HashSet<Locale> = hashset![new_locale.clone()];
188        self.enumerate_fallbacks(new_locale.clone(), &mut to_load);
189
190        let mut new_assets: HashMap<Locale, Arc<fluent::FluentBundle<fluent::FluentResource>>> = hashmap![];
191        for locale in to_load {
192            let res = self.load_single_locale(&locale).await;
193            if res.is_none() {
194                return false;
195            }
196            new_assets.insert(locale.clone(), res.unwrap());
197        }
198        if self.m_assets_clean_unused {
199            self.m_assets.write().unwrap().clear();
200        }
201
202        for (locale, bundle) in new_assets {
203            self.m_assets.write().unwrap().insert(locale, bundle.clone());
204        }
205        *self.m_current_locale.write().unwrap() = Some(new_locale.clone());
206        for c in self.m_locale_initializers.read().unwrap().iter() {
207            c(new_locale.clone(), self.m_assets.read().unwrap()[&new_locale.clone()].clone());
208        }
209
210        true
211    }
212
213    async fn load_single_locale(&self, locale: &Locale) -> Option<Arc<fluent::FluentBundle<fluent::FluentResource>>> {
214        let mut r = fluent::FluentBundle::new(vec![locale_to_unic_langid_impl_langid(locale)]);
215        match self.m_assets_load_method {
216            FtlLoadMethod::FileSystem => {
217                for file_name in self.m_assets_files.iter() {
218                    let locale_path_comp = self.m_locale_to_path_components.get(locale);
219                    if locale_path_comp.is_none() {
220                        panic!("Fallback is not supported a locale: {}", locale.to_string());
221                    }
222                    let res_path = format!("{}/{}/{}.ftl", self.m_assets_source, locale_path_comp.unwrap(), file_name);
223                    let source = rialight_filesystem::File::new(res_path.clone()).read_bytes();
224                    if source.is_err() {
225                        println!("Failed to load resource at {}.", res_path);
226                        return None;
227                    }
228                    let source = String::from_utf8(source.unwrap()).unwrap();
229                    if !add_ftl_bundle_resource(file_name.clone(), source, &mut r) {
230                        return None;
231                    }
232                }
233            },
234            FtlLoadMethod::Http => {
235                for file_name in self.m_assets_files.iter() {
236                    let locale_path_comp = self.m_locale_to_path_components.get(locale);
237                    if locale_path_comp.is_none() {
238                        panic!("Fallback is not supported a locale: {}", locale.to_string());
239                    }
240                    let res_path = format!("{}/{}/{}.ftl", self.m_assets_source, locale_path_comp.unwrap(), file_name);
241                    let source = reqwest::get(reqwest::Url::parse(res_path.clone().as_ref()).unwrap()).await;
242                    if source.is_err() {
243                        println!("Failed to load resource at {}.", res_path);
244                        return None;
245                    }
246                    let source = source.unwrap().text().await;
247                    if source.is_err() {
248                        println!("Failed to load resource at {}.", res_path);
249                        return None;
250                    }
251                    let source = source.unwrap();
252                    if !add_ftl_bundle_resource(file_name.clone(), source, &mut r) {
253                        return None;
254                    }
255                }
256            },
257        }
258        Some(Arc::new(r))
259    }
260
261    fn enumerate_fallbacks(&self, locale: Locale, output: &mut HashSet<Locale>) {
262        for list in self.m_fallbacks.get(&locale).iter() {
263            for item in list.iter() {
264                output.insert(item.clone());
265                self.enumerate_fallbacks(item.clone(), output);
266            }
267        }
268    }
269
270    pub fn get_message(&self, id: &str, args: Option<&Arguments>, errors: &mut Vec<fluent::FluentError>) -> Option<String> {
271        self.get_message_by_locale(id, self.m_current_locale.read().unwrap().clone()?, args, errors)
272    }
273
274    fn get_message_by_locale(&self, id: &str, locale: Locale, args: Option<&Arguments>, errors: &mut Vec<fluent::FluentError>) -> Option<String> {
275        if let Some(assets) = self.m_assets.read().unwrap().get(&locale) {
276            if let Some(message) = assets.get_message(id) {
277                return Some(self.format_pattern(message.value()?, args, errors));
278            }
279        }
280
281        let fallbacks = self.m_fallbacks.get(&locale);
282        if fallbacks.is_some() {
283            for fl in fallbacks.unwrap().iter() {
284                let r = self.get_message_by_locale(id, fl.clone(), args, errors);
285                if r.is_some() {
286                    return r;
287                }
288            }
289        }
290        None
291    }
292
293    pub fn has_message(&self, id: &str) -> bool {
294        let locale = self.m_current_locale.read().unwrap().clone();
295        if locale.is_none() {
296            return false;
297        }
298        self.has_message_by_locale(id, locale.unwrap())
299    }
300
301    fn has_message_by_locale(&self, id: &str, locale: Locale) -> bool {
302        let assets = self.m_assets.read().unwrap();
303        let assets = assets.get(&locale);
304        if assets.is_some() {
305            if assets.unwrap().has_message(id) {
306                return true;
307            }
308        }
309
310        let fallbacks = self.m_fallbacks.get(&locale);
311        if fallbacks.is_some() {
312            for fl in fallbacks.unwrap().iter() {
313                let r = self.has_message_by_locale(id, fl.clone());
314                if r {
315                    return true;
316                }
317            }
318        }
319        false
320    }
321
322    pub fn format_pattern(&self, pattern: &fluent_syntax::ast::Pattern<&str>, args: Option<&Arguments>, errors: &mut Vec<fluent::FluentError>) -> String {
323        let locale = self.m_current_locale.read().unwrap().clone();
324        if locale.is_none() {
325            return "".to_owned();
326        }
327        let asset = &self.m_assets.read().unwrap()[&locale.unwrap()];
328        asset.format_pattern(pattern, args, errors).into_owned().to_owned()
329    }
330}
331
332impl Clone for Ftl {
333    fn clone(&self) -> Self {
334        Self {
335            m_current_locale: RwLock::new(self.m_current_locale.read().unwrap().clone()),
336            m_locale_to_path_components: self.m_locale_to_path_components.clone(),
337            m_supported_locales: self.m_supported_locales.clone(),
338            m_default_locale: self.m_default_locale.clone(),
339            m_fallbacks: self.m_fallbacks.clone(),
340            m_locale_initializers: self.m_locale_initializers.clone(),
341            m_assets: self.m_assets.clone(),
342            m_assets_source: self.m_assets_source.clone(),
343            m_assets_files: self.m_assets_files.clone(),
344            m_assets_clean_unused: self.m_assets_clean_unused,
345            m_assets_load_method: self.m_assets_load_method,
346        }
347    }
348}
349
350/// Options given to the Ftl constructor.
351pub struct FtlOptions {
352    m_default_locale: RwLock<String>,
353    m_supported_locales: RwLock<Vec<String>>,
354    m_fallbacks: RwLock<HashMap<String, Vec<String>>>,
355    m_assets: RwLock<FtlOptionsForAssets>,
356}
357
358impl FtlOptions {
359    pub fn new() -> Self {
360        FtlOptions {
361            m_default_locale: RwLock::new("en".to_string()),
362            m_supported_locales: RwLock::new(vec!["en".to_string()]),
363            m_fallbacks: RwLock::new(hashmap! {}),
364            m_assets: RwLock::new(FtlOptionsForAssets::new()),
365        }
366    }
367
368    pub fn default_locale(&mut self, value: impl AsRef<str>) -> &mut Self {
369        *self.m_default_locale.write().unwrap() = value.as_ref().to_owned();
370        self
371    }
372
373    pub fn supported_locales(&mut self, list: Vec<impl AsRef<str>>) -> &mut Self {
374        *self.m_supported_locales.write().unwrap() = list.iter().map(|name| name.as_ref().to_owned()).collect();
375        self
376    }
377
378    pub fn fallbacks(&mut self, map: HashMap<impl AsRef<str>, Vec<impl AsRef<str>>>) -> &mut Self {
379        *self.m_fallbacks.write().unwrap() = map.iter().map(|(k, v)| (
380            k.as_ref().to_owned(),
381            v.iter().map(|s| s.as_ref().to_owned()).collect()
382        )).collect();
383        self
384    }
385
386    pub fn assets(&mut self, options: &FtlOptionsForAssets) -> &mut Self {
387        *self.m_assets.write().unwrap() = options.clone();
388        self
389    }
390}
391
392pub struct FtlOptionsForAssets {
393    m_source: RwLock<String>,
394    m_files: RwLock<Vec<String>>,
395    m_clean_unused: Cell<bool>,
396    m_load_method: Cell<FtlLoadMethod>,
397}
398
399impl Clone for FtlOptionsForAssets {
400    fn clone(&self) -> Self {
401        Self {
402            m_source: RwLock::new(self.m_source.read().unwrap().clone()),
403            m_files: RwLock::new(self.m_files.read().unwrap().clone()),
404            m_clean_unused: self.m_clean_unused.clone(),
405            m_load_method: self.m_load_method.clone(),
406        }
407    }
408}
409
410impl FtlOptionsForAssets {
411    pub fn new() -> Self {
412        FtlOptionsForAssets {
413            m_source: RwLock::new("res/lang".to_string()),
414            m_files: RwLock::new(vec![]),
415            m_clean_unused: Cell::new(true),
416            m_load_method: Cell::new(FtlLoadMethod::Http),
417        }
418    }
419    
420    pub fn source(&mut self, src: impl AsRef<str>) -> &mut Self {
421        *self.m_source.write().unwrap() = src.as_ref().to_owned();
422        self
423    } 
424
425    pub fn files(&mut self, list: Vec<impl AsRef<str>>) -> &mut Self {
426        *self.m_files.write().unwrap() = list.iter().map(|name| name.as_ref().to_owned()).collect();
427        self
428    }
429
430    pub fn clean_unused(&mut self, value: bool) -> &mut Self {
431        self.m_clean_unused.set(value);
432        self
433    }
434
435    pub fn load_method(&mut self, value: FtlLoadMethod) -> &mut Self {
436        self.m_load_method.set(value);
437        self
438    }
439}
440
441#[derive(Copy, Clone, PartialEq)]
442pub enum FtlLoadMethod {
443    FileSystem,
444    Http,
445}