freya_i18n/
i18n.rs

1use std::{
2    collections::HashMap,
3    path::PathBuf,
4};
5
6use fluent::{
7    FluentArgs,
8    FluentBundle,
9    FluentResource,
10};
11use freya_core::prelude::*;
12use unic_langid::LanguageIdentifier;
13
14use super::error::Error;
15
16/// `Locale` is a "place-holder" around what will eventually be a `fluent::FluentBundle`
17#[cfg_attr(test, derive(Debug, PartialEq))]
18pub struct Locale {
19    id: LanguageIdentifier,
20    resource: LocaleResource,
21}
22
23impl Locale {
24    pub fn new_static(id: LanguageIdentifier, str: &'static str) -> Self {
25        Self {
26            id,
27            resource: LocaleResource::Static(str),
28        }
29    }
30
31    pub fn new_dynamic(id: LanguageIdentifier, path: impl Into<PathBuf>) -> Self {
32        Self {
33            id,
34            resource: LocaleResource::Path(path.into()),
35        }
36    }
37}
38
39impl<T> From<(LanguageIdentifier, T)> for Locale
40where
41    T: Into<LocaleResource>,
42{
43    fn from((id, resource): (LanguageIdentifier, T)) -> Self {
44        let resource = resource.into();
45        Self { id, resource }
46    }
47}
48
49/// A `LocaleResource` can be static text, or a filesystem file.
50#[derive(Debug, PartialEq)]
51pub enum LocaleResource {
52    Static(&'static str),
53
54    Path(PathBuf),
55}
56
57impl LocaleResource {
58    pub fn try_to_resource_string(&self) -> Result<String, Error> {
59        match self {
60            Self::Static(str) => Ok(str.to_string()),
61
62            Self::Path(path) => std::fs::read_to_string(path)
63                .map_err(|e| Error::LocaleResourcePathReadFailed(e.to_string())),
64        }
65    }
66
67    pub fn to_resource_string(&self) -> String {
68        let result = self.try_to_resource_string();
69        match result {
70            Ok(string) => string,
71            Err(err) => panic!("failed to create resource string {self:?}: {err}"),
72        }
73    }
74}
75
76impl From<&'static str> for LocaleResource {
77    fn from(value: &'static str) -> Self {
78        Self::Static(value)
79    }
80}
81
82impl From<PathBuf> for LocaleResource {
83    fn from(value: PathBuf) -> Self {
84        Self::Path(value)
85    }
86}
87
88/// The configuration for `I18n`.
89#[derive(Debug, PartialEq)]
90pub struct I18nConfig {
91    /// The initial language, can be later changed with [`I18n::set_language`]
92    pub id: LanguageIdentifier,
93
94    /// The final fallback language if no other locales are found for `id`.
95    /// A `Locale` must exist in `locales' if `fallback` is defined.
96    pub fallback: Option<LanguageIdentifier>,
97
98    /// The locale_resources added to the configuration.
99    pub locale_resources: Vec<LocaleResource>,
100
101    /// The locales added to the configuration.
102    pub locales: HashMap<LanguageIdentifier, usize>,
103}
104
105impl I18nConfig {
106    /// Create an i18n config with the selected [LanguageIdentifier].
107    pub fn new(id: LanguageIdentifier) -> Self {
108        Self {
109            id,
110            fallback: None,
111            locale_resources: Vec::new(),
112            locales: HashMap::new(),
113        }
114    }
115
116    /// Set a fallback [LanguageIdentifier].
117    pub fn with_fallback(mut self, fallback: LanguageIdentifier) -> Self {
118        self.fallback = Some(fallback);
119        self
120    }
121
122    /// Add [Locale].
123    /// It is possible to share locales resources. If this locale's resource
124    /// matches a previously added one, then this locale will use the existing one.
125    /// This is primarily for the static locale_resources to avoid string duplication.
126    pub fn with_locale<T>(mut self, locale: T) -> Self
127    where
128        T: Into<Locale>,
129    {
130        let locale = locale.into();
131        let locale_resources_len = self.locale_resources.len();
132
133        let index = self
134            .locale_resources
135            .iter()
136            .position(|r| *r == locale.resource)
137            .unwrap_or(locale_resources_len);
138
139        if index == locale_resources_len {
140            self.locale_resources.push(locale.resource)
141        };
142
143        self.locales.insert(locale.id, index);
144        self
145    }
146
147    /// Add multiple locales from given folder, based on their filename.
148    ///
149    /// If the path represents a folder, then the folder will be deep traversed for
150    /// all '*.ftl' files. If the filename represents a [LanguageIdentifier] then it
151    ///  will be added to the config.
152    ///
153    /// If the path represents a file, then the filename must represent a
154    /// unic_langid::LanguageIdentifier for it to be added to the config.
155    #[cfg(feature = "discovery")]
156    pub fn try_with_auto_locales(self, path: PathBuf) -> Result<Self, Error> {
157        if path.is_dir() {
158            let files = find_ftl_files(&path)?;
159            files
160                .into_iter()
161                .try_fold(self, |acc, file| acc.with_auto_pathbuf(file))
162        } else if is_ftl_file(&path) {
163            self.with_auto_pathbuf(path)
164        } else {
165            Err(Error::InvalidPath(path.to_string_lossy().to_string()))
166        }
167    }
168
169    #[cfg(feature = "discovery")]
170    fn with_auto_pathbuf(self, file: PathBuf) -> Result<Self, Error> {
171        assert!(is_ftl_file(&file));
172
173        let stem = file.file_stem().ok_or_else(|| {
174            Error::InvalidLanguageId(format!("No file stem: '{}'", file.display()))
175        })?;
176
177        let id_str = stem.to_str().ok_or_else(|| {
178            Error::InvalidLanguageId(format!("Cannot convert: {}", stem.to_string_lossy()))
179        })?;
180
181        let id = LanguageIdentifier::from_bytes(id_str.as_bytes())
182            .map_err(|e| Error::InvalidLanguageId(e.to_string()))?;
183
184        Ok(self.with_locale((id, file)))
185    }
186
187    /// Add multiple locales from given folder, based on their filename.
188    ///
189    /// Will panic! on error.
190    #[cfg(feature = "discovery")]
191    pub fn with_auto_locales(self, path: PathBuf) -> Self {
192        let path_name = path.display().to_string();
193        let result = self.try_with_auto_locales(path);
194        match result {
195            Ok(result) => result,
196            Err(err) => panic!("with_auto_locales must have valid pathbuf {path_name}: {err}",),
197        }
198    }
199}
200
201#[cfg(feature = "discovery")]
202fn find_ftl_files(folder: &PathBuf) -> Result<Vec<PathBuf>, Error> {
203    let ftl_files: Vec<PathBuf> = walkdir::WalkDir::new(folder)
204        .into_iter()
205        .filter_map(|entry| entry.ok())
206        .filter(|entry| is_ftl_file(entry.path()))
207        .map(|entry| entry.path().to_path_buf())
208        .collect();
209
210    Ok(ftl_files)
211}
212
213#[cfg(feature = "discovery")]
214fn is_ftl_file(entry: &std::path::Path) -> bool {
215    entry.is_file() && entry.extension().map(|ext| ext == "ftl").unwrap_or(false)
216}
217
218/// Provide an existing [`I18n`] to descendant components.
219/// This is useful for sharing the same global state across different parts of the component tree
220/// or across multiple windows.
221pub fn use_share_i18n(i18n: impl FnOnce() -> I18n) {
222    use_provide_context(i18n);
223}
224
225/// Initialize the i18n provider.
226///
227/// See [I18n::create] for a manual I18n initialization where you can also handle errors.
228pub fn use_init_i18n(init: impl FnOnce() -> I18nConfig) -> I18n {
229    use_provide_context(move || {
230        // Coverage false -ve: See https://github.com/xd009642/tarpaulin/issues/1675
231        match I18n::create(init()) {
232            Ok(i18n) => i18n,
233            Err(e) => panic!("Failed to create I18n context: {e}"),
234        }
235    })
236}
237
238#[derive(Clone, Copy)]
239pub struct I18n {
240    selected_language: State<LanguageIdentifier>,
241    fallback_language: State<Option<LanguageIdentifier>>,
242    locale_resources: State<Vec<LocaleResource>>,
243    locales: State<HashMap<LanguageIdentifier, usize>>,
244    active_bundle: State<FluentBundle<FluentResource>>,
245}
246
247impl I18n {
248    pub fn try_get() -> Option<Self> {
249        try_consume_context()
250    }
251
252    pub fn get() -> Self {
253        consume_context()
254    }
255
256    pub fn create(
257        I18nConfig {
258            id: selected_language,
259            fallback: fallback_language,
260            locale_resources,
261            locales,
262        }: I18nConfig,
263    ) -> Result<Self, Error> {
264        let bundle = try_create_bundle(
265            &selected_language,
266            &fallback_language,
267            &locale_resources,
268            &locales,
269        )?;
270        Ok(Self {
271            selected_language: State::create(selected_language),
272            fallback_language: State::create(fallback_language),
273            locale_resources: State::create(locale_resources),
274            locales: State::create(locales),
275            active_bundle: State::create(bundle),
276        })
277    }
278
279    pub fn create_global(
280        I18nConfig {
281            id: selected_language,
282            fallback: fallback_language,
283            locale_resources,
284            locales,
285        }: I18nConfig,
286    ) -> Result<Self, Error> {
287        let bundle = try_create_bundle(
288            &selected_language,
289            &fallback_language,
290            &locale_resources,
291            &locales,
292        )?;
293        Ok(Self {
294            selected_language: State::create_global(selected_language),
295            fallback_language: State::create_global(fallback_language),
296            locale_resources: State::create_global(locale_resources),
297            locales: State::create_global(locales),
298            active_bundle: State::create_global(bundle),
299        })
300    }
301
302    pub fn try_translate_with_args(
303        &self,
304        msg: &str,
305        args: Option<&FluentArgs>,
306    ) -> Result<String, Error> {
307        let (message_id, attribute_name) = Self::decompose_identifier(msg)?;
308
309        let bundle = self.active_bundle.read();
310
311        let message = bundle
312            .get_message(message_id)
313            .ok_or_else(|| Error::MessageIdNotFound(message_id.into()))?;
314
315        let pattern = if let Some(attribute_name) = attribute_name {
316            let attribute = message
317                .get_attribute(attribute_name)
318                .ok_or_else(|| Error::AttributeIdNotFound(msg.to_string()))?;
319            attribute.value()
320        } else {
321            message
322                .value()
323                .ok_or_else(|| Error::MessagePatternNotFound(message_id.into()))?
324        };
325
326        let mut errors = vec![];
327        let translation = bundle
328            .format_pattern(pattern, args, &mut errors)
329            .to_string();
330
331        (errors.is_empty())
332            .then_some(translation)
333            .ok_or_else(|| Error::FluentErrorsDetected(format!("{errors:#?}")))
334    }
335
336    pub fn decompose_identifier(msg: &str) -> Result<(&str, Option<&str>), Error> {
337        let parts: Vec<&str> = msg.split('.').collect();
338        match parts.as_slice() {
339            [message_id] => Ok((message_id, None)),
340            [message_id, attribute_name] => Ok((message_id, Some(attribute_name))),
341            _ => Err(Error::InvalidMessageId(msg.to_string())),
342        }
343    }
344
345    pub fn translate_with_args(&self, msg: &str, args: Option<&FluentArgs>) -> String {
346        let result = self.try_translate_with_args(msg, args);
347        match result {
348            Ok(translation) => translation,
349            Err(err) => panic!("Failed to translate {msg}: {err}"),
350        }
351    }
352
353    #[inline]
354    pub fn try_translate(&self, msg: &str) -> Result<String, Error> {
355        self.try_translate_with_args(msg, None)
356    }
357
358    pub fn translate(&self, msg: &str) -> String {
359        let result = self.try_translate(msg);
360        match result {
361            Ok(translation) => translation,
362            Err(err) => panic!("Failed to translate {msg}: {err}"),
363        }
364    }
365
366    /// Get the selected language.
367    #[inline]
368    pub fn language(&self) -> LanguageIdentifier {
369        self.selected_language.read().clone()
370    }
371
372    /// Get the fallback language.
373    pub fn fallback_language(&self) -> Option<LanguageIdentifier> {
374        self.fallback_language.read().clone()
375    }
376
377    /// Update the selected language.
378    pub fn try_set_language(&mut self, id: LanguageIdentifier) -> Result<(), Error> {
379        *self.selected_language.write() = id;
380        self.try_update_active_bundle()
381    }
382
383    /// Update the selected language.
384    pub fn set_language(&mut self, id: LanguageIdentifier) {
385        let id_name = id.to_string();
386        let result = self.try_set_language(id);
387        match result {
388            Ok(()) => (),
389            Err(err) => panic!("cannot set language {id_name}: {err}"),
390        }
391    }
392
393    /// Update the fallback language.
394    pub fn try_set_fallback_language(&mut self, id: LanguageIdentifier) -> Result<(), Error> {
395        self.locales
396            .read()
397            .get(&id)
398            .ok_or_else(|| Error::FallbackMustHaveLocale(id.to_string()))?;
399
400        *self.fallback_language.write() = Some(id);
401        self.try_update_active_bundle()
402    }
403
404    /// Update the fallback language.
405    pub fn set_fallback_language(&mut self, id: LanguageIdentifier) {
406        let id_name = id.to_string();
407        let result = self.try_set_fallback_language(id);
408        match result {
409            Ok(()) => (),
410            Err(err) => panic!("cannot set fallback language {id_name}: {err}"),
411        }
412    }
413
414    fn try_update_active_bundle(&mut self) -> Result<(), Error> {
415        let bundle = try_create_bundle(
416            &self.selected_language.peek(),
417            &self.fallback_language.peek(),
418            &self.locale_resources.peek(),
419            &self.locales.peek(),
420        )?;
421
422        self.active_bundle.set(bundle);
423        Ok(())
424    }
425}
426
427fn try_create_bundle(
428    selected_language: &LanguageIdentifier,
429    fallback_language: &Option<LanguageIdentifier>,
430    locale_resources: &[LocaleResource],
431    locales: &HashMap<LanguageIdentifier, usize>,
432) -> Result<FluentBundle<FluentResource>, Error> {
433    let add_resource = move |bundle: &mut FluentBundle<FluentResource>,
434                             langid: &LanguageIdentifier,
435                             locale_resources: &[LocaleResource]| {
436        if let Some(&i) = locales.get(langid) {
437            let resource = &locale_resources[i];
438            let resource =
439                FluentResource::try_new(resource.try_to_resource_string()?).map_err(|e| {
440                    Error::FluentErrorsDetected(format!("resource langid: {langid}\n{e:#?}"))
441                })?;
442            bundle.add_resource_overriding(resource);
443        };
444        Ok(())
445    };
446
447    let mut bundle = FluentBundle::new(vec![selected_language.clone()]);
448    if let Some(fallback_language) = fallback_language {
449        add_resource(&mut bundle, fallback_language, locale_resources)?;
450    }
451
452    let (language, script, region, variants) = selected_language.clone().into_parts();
453    let variants_lang = LanguageIdentifier::from_parts(language, script, region, &variants);
454    let region_lang = LanguageIdentifier::from_parts(language, script, region, &[]);
455    let script_lang = LanguageIdentifier::from_parts(language, script, None, &[]);
456    let language_lang = LanguageIdentifier::from_parts(language, None, None, &[]);
457
458    add_resource(&mut bundle, &language_lang, locale_resources)?;
459    add_resource(&mut bundle, &script_lang, locale_resources)?;
460    add_resource(&mut bundle, &region_lang, locale_resources)?;
461    add_resource(&mut bundle, &variants_lang, locale_resources)?;
462
463    /* Add this code when the fluent crate includes FluentBundle::add_builtins.
464     * This will allow the use of built-in functions like `NUMBER` and `DATETIME`.
465     * See [Fluent issue](https://github.com/projectfluent/fluent-rs/issues/181) for more information.
466    bundle
467        .add_builtins()
468        .map_err(|e| Error::FluentErrorsDetected(e.to_string()))?;
469    */
470
471    Ok(bundle)
472}