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/// Initialize the i18n provider.
219///
220/// See [I18n::new] for a manual I18n initilization where you can also handle errors.
221pub fn use_init_i18n(init: impl FnOnce() -> I18nConfig) -> I18n {
222    use_provide_context(move || {
223        // Coverage false -ve: See https://github.com/xd009642/tarpaulin/issues/1675
224        let I18nConfig {
225            id,
226            fallback,
227            locale_resources,
228            locales,
229        } = init();
230
231        match I18n::new(id, fallback, locale_resources, locales) {
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 new(
257        selected_language: LanguageIdentifier,
258        fallback_language: Option<LanguageIdentifier>,
259        locale_resources: Vec<LocaleResource>,
260        locales: HashMap<LanguageIdentifier, usize>,
261    ) -> Result<Self, Error> {
262        let bundle = try_create_bundle(
263            &selected_language,
264            &fallback_language,
265            &locale_resources,
266            &locales,
267        )?;
268        Ok(Self {
269            selected_language: State::create(selected_language),
270            fallback_language: State::create(fallback_language),
271            locale_resources: State::create(locale_resources),
272            locales: State::create(locales),
273            active_bundle: State::create(bundle),
274        })
275    }
276
277    pub fn try_translate_with_args(
278        &self,
279        msg: &str,
280        args: Option<&FluentArgs>,
281    ) -> Result<String, Error> {
282        let (message_id, attribute_name) = Self::decompose_identifier(msg)?;
283
284        let bundle = self.active_bundle.read();
285
286        let message = bundle
287            .get_message(message_id)
288            .ok_or_else(|| Error::MessageIdNotFound(message_id.into()))?;
289
290        let pattern = if let Some(attribute_name) = attribute_name {
291            let attribute = message
292                .get_attribute(attribute_name)
293                .ok_or_else(|| Error::AttributeIdNotFound(msg.to_string()))?;
294            attribute.value()
295        } else {
296            message
297                .value()
298                .ok_or_else(|| Error::MessagePatternNotFound(message_id.into()))?
299        };
300
301        let mut errors = vec![];
302        let translation = bundle
303            .format_pattern(pattern, args, &mut errors)
304            .to_string();
305
306        (errors.is_empty())
307            .then_some(translation)
308            .ok_or_else(|| Error::FluentErrorsDetected(format!("{errors:#?}")))
309    }
310
311    pub fn decompose_identifier(msg: &str) -> Result<(&str, Option<&str>), Error> {
312        let parts: Vec<&str> = msg.split('.').collect();
313        match parts.as_slice() {
314            [message_id] => Ok((message_id, None)),
315            [message_id, attribute_name] => Ok((message_id, Some(attribute_name))),
316            _ => Err(Error::InvalidMessageId(msg.to_string())),
317        }
318    }
319
320    pub fn translate_with_args(&self, msg: &str, args: Option<&FluentArgs>) -> String {
321        let result = self.try_translate_with_args(msg, args);
322        match result {
323            Ok(translation) => translation,
324            Err(err) => panic!("Failed to translate {msg}: {err}"),
325        }
326    }
327
328    #[inline]
329    pub fn try_translate(&self, msg: &str) -> Result<String, Error> {
330        self.try_translate_with_args(msg, None)
331    }
332
333    pub fn translate(&self, msg: &str) -> String {
334        let result = self.try_translate(msg);
335        match result {
336            Ok(translation) => translation,
337            Err(err) => panic!("Failed to translate {msg}: {err}"),
338        }
339    }
340
341    /// Get the selected language.
342    #[inline]
343    pub fn language(&self) -> LanguageIdentifier {
344        self.selected_language.read().clone()
345    }
346
347    /// Get the fallback language.
348    pub fn fallback_language(&self) -> Option<LanguageIdentifier> {
349        self.fallback_language.read().clone()
350    }
351
352    /// Update the selected language.
353    pub fn try_set_language(&mut self, id: LanguageIdentifier) -> Result<(), Error> {
354        *self.selected_language.write() = id;
355        self.try_update_active_bundle()
356    }
357
358    /// Update the selected language.
359    pub fn set_language(&mut self, id: LanguageIdentifier) {
360        let id_name = id.to_string();
361        let result = self.try_set_language(id);
362        match result {
363            Ok(()) => (),
364            Err(err) => panic!("cannot set language {id_name}: {err}"),
365        }
366    }
367
368    /// Update the fallback language.
369    pub fn try_set_fallback_language(&mut self, id: LanguageIdentifier) -> Result<(), Error> {
370        self.locales
371            .read()
372            .get(&id)
373            .ok_or_else(|| Error::FallbackMustHaveLocale(id.to_string()))?;
374
375        *self.fallback_language.write() = Some(id);
376        self.try_update_active_bundle()
377    }
378
379    /// Update the fallback language.
380    pub fn set_fallback_language(&mut self, id: LanguageIdentifier) {
381        let id_name = id.to_string();
382        let result = self.try_set_fallback_language(id);
383        match result {
384            Ok(()) => (),
385            Err(err) => panic!("cannot set fallback language {id_name}: {err}"),
386        }
387    }
388
389    fn try_update_active_bundle(&mut self) -> Result<(), Error> {
390        let bundle = try_create_bundle(
391            &self.selected_language.peek(),
392            &self.fallback_language.peek(),
393            &self.locale_resources.peek(),
394            &self.locales.peek(),
395        )?;
396
397        self.active_bundle.set(bundle);
398        Ok(())
399    }
400}
401
402fn try_create_bundle(
403    selected_language: &LanguageIdentifier,
404    fallback_language: &Option<LanguageIdentifier>,
405    locale_resources: &[LocaleResource],
406    locales: &HashMap<LanguageIdentifier, usize>,
407) -> Result<FluentBundle<FluentResource>, Error> {
408    let add_resource = move |bundle: &mut FluentBundle<FluentResource>,
409                             langid: &LanguageIdentifier,
410                             locale_resources: &[LocaleResource]| {
411        if let Some(&i) = locales.get(langid) {
412            let resource = &locale_resources[i];
413            let resource =
414                FluentResource::try_new(resource.try_to_resource_string()?).map_err(|e| {
415                    Error::FluentErrorsDetected(format!("resource langid: {langid}\n{e:#?}"))
416                })?;
417            bundle.add_resource_overriding(resource);
418        };
419        Ok(())
420    };
421
422    let mut bundle = FluentBundle::new(vec![selected_language.clone()]);
423    if let Some(fallback_language) = fallback_language {
424        add_resource(&mut bundle, fallback_language, locale_resources)?;
425    }
426
427    let (language, script, region, variants) = selected_language.clone().into_parts();
428    let variants_lang = LanguageIdentifier::from_parts(language, script, region, &variants);
429    let region_lang = LanguageIdentifier::from_parts(language, script, region, &[]);
430    let script_lang = LanguageIdentifier::from_parts(language, script, None, &[]);
431    let language_lang = LanguageIdentifier::from_parts(language, None, None, &[]);
432
433    add_resource(&mut bundle, &language_lang, locale_resources)?;
434    add_resource(&mut bundle, &script_lang, locale_resources)?;
435    add_resource(&mut bundle, &region_lang, locale_resources)?;
436    add_resource(&mut bundle, &variants_lang, locale_resources)?;
437
438    /* Add this code when the fluent crate includes FluentBundle::add_builtins.
439     * This will allow the use of built-in functions like `NUMBER` and `DATETIME`.
440     * See [Fluent issue](https://github.com/projectfluent/fluent-rs/issues/181) for more information.
441    bundle
442        .add_builtins()
443        .map_err(|e| Error::FluentErrorsDetected(e.to_string()))?;
444    */
445
446    Ok(bundle)
447}