i18n_embed/
fluent.rs

1//! This module contains the types and functions to interact with the
2//! `fluent` localization system.
3//!
4//! Most important is the [FluentLanguageLoader].
5//!
6//! ⚠️ *This module requires the following crate features to be activated: `fluent-system`.*
7
8use crate::{I18nAssets, I18nEmbedError, LanguageLoader};
9
10use arc_swap::ArcSwap;
11pub use fluent_langneg::NegotiationStrategy;
12pub use i18n_embed_impl::fluent_language_loader;
13
14use fluent::{
15    bundle::FluentBundle, FluentArgs, FluentAttribute, FluentMessage, FluentResource, FluentValue,
16};
17use fluent_syntax::ast::{self, Pattern};
18use intl_memoizer::concurrent::IntlLangMemoizer;
19use parking_lot::RwLock;
20use std::{borrow::Cow, collections::HashMap, fmt::Debug, iter::FromIterator, sync::Arc};
21use unic_langid::LanguageIdentifier;
22
23struct LanguageBundle {
24    language: LanguageIdentifier,
25    bundle: FluentBundle<Arc<FluentResource>, IntlLangMemoizer>,
26    resource: Arc<FluentResource>,
27}
28
29impl LanguageBundle {
30    fn new(language: LanguageIdentifier, resource: FluentResource) -> Self {
31        let mut bundle = FluentBundle::new_concurrent(vec![language.clone()]);
32        let resource = Arc::new(resource);
33        if let Err(errors) = bundle.add_resource(resource.clone()) {
34            errors.iter().for_each(|error | {
35                log::error!(target: "i18n_embed::fluent", "Error while adding resource to bundle: {0:?}.", error);
36            })
37        }
38        Self {
39            language,
40            bundle,
41            resource,
42        }
43    }
44}
45
46impl Debug for LanguageBundle {
47    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
48        write!(f, "LanguageBundle(language: {})", self.language)
49    }
50}
51
52#[derive(Debug)]
53struct LanguageConfig {
54    /// Storage for language localization resources. Outer `Vec` is per language (as specified in
55    /// [`LanguageConfig::language_map`]), inner Vec is for storage of multiple bundles per
56    /// language, in order of priority (highest to lowest).
57    language_bundles: Vec<Vec<LanguageBundle>>,
58    /// This maps a `LanguageIdentifier` to the index inside the
59    /// `language_bundles` vector.
60    language_map: HashMap<LanguageIdentifier, usize>,
61}
62
63#[derive(Debug)]
64struct CurrentLanguages {
65    /// Languages currently selected.
66    languages: Vec<LanguageIdentifier>,
67    /// Indexes into the [`LanguageConfig::language_bundles`] associated the
68    /// currently selected [`CurrentLanguages::languages`].
69    indices: Vec<usize>,
70}
71
72#[derive(Debug)]
73struct FluentLanguageLoaderInner {
74    language_config: Arc<RwLock<LanguageConfig>>,
75    current_languages: CurrentLanguages,
76}
77
78/// [LanguageLoader] implementation for the `fluent` localization
79/// system. Also provides methods to access localizations which have
80/// been loaded.
81///
82/// ⚠️ *This API requires the following crate features to be activated: `fluent-system`.*
83#[derive(Debug)]
84pub struct FluentLanguageLoader {
85    inner: ArcSwap<FluentLanguageLoaderInner>,
86    domain: String,
87    fallback_language: unic_langid::LanguageIdentifier,
88}
89
90impl FluentLanguageLoader {
91    /// Create a new `FluentLanguageLoader`, which loads messages for
92    /// the specified `domain`, and relies on the specified
93    /// `fallback_language` for any messages that do not exist for the
94    /// current language.
95    pub fn new<S: Into<String>>(
96        domain: S,
97        fallback_language: unic_langid::LanguageIdentifier,
98    ) -> Self {
99        let config = LanguageConfig {
100            language_bundles: Vec::new(),
101            language_map: HashMap::new(),
102        };
103
104        Self {
105            inner: ArcSwap::new(Arc::new(FluentLanguageLoaderInner {
106                language_config: Arc::new(RwLock::new(config)),
107                current_languages: CurrentLanguages {
108                    languages: vec![fallback_language.clone()],
109                    indices: vec![],
110                },
111            })),
112            domain: domain.into(),
113            fallback_language,
114        }
115    }
116
117    fn current_language_impl(
118        &self,
119        inner: &FluentLanguageLoaderInner,
120    ) -> unic_langid::LanguageIdentifier {
121        inner
122            .current_languages
123            .languages
124            .first()
125            .map_or_else(|| self.fallback_language.clone(), Clone::clone)
126    }
127
128    /// The languages associated with each actual currently loaded language bundle.
129    pub fn current_languages(&self) -> Vec<unic_langid::LanguageIdentifier> {
130        self.inner.load().current_languages.languages.clone()
131    }
132
133    /// Get a localized message referenced by the `message_id`.
134    pub fn get(&self, message_id: &str) -> String {
135        self.get_args_fluent(message_id, None)
136    }
137
138    /// A non-generic version of [FluentLanguageLoader::get_args()].
139    pub fn get_args_concrete<'args>(
140        &self,
141        message_id: &str,
142        args: HashMap<&'args str, FluentValue<'args>>,
143    ) -> String {
144        self.get_args_fluent(message_id, hash_map_to_fluent_args(args).as_ref())
145    }
146
147    /// A non-generic version of [FluentLanguageLoader::get_args()]
148    /// accepting [FluentArgs] instead of a [HashMap].
149    pub fn get_args_fluent<'args>(
150        &self,
151        message_id: &str,
152        args: Option<&'args FluentArgs<'args>>,
153    ) -> String {
154        let inner = self.inner.load();
155        let language_config = inner.language_config.read();
156        inner
157            .current_languages
158            .indices
159            .iter()
160            .map(|&idx| &language_config.language_bundles[idx])
161            .flat_map(|language_bundles| language_bundles.iter())
162            .find_map(|language_bundle| language_bundle
163                .bundle
164                .get_message(message_id)
165                .and_then(|m: FluentMessage<'_>| m.value())
166                .map(|pattern: &Pattern<&str>| {
167                    let mut errors = Vec::new();
168                    let value = language_bundle.bundle.format_pattern(pattern, args, &mut errors);
169                    if !errors.is_empty() {
170                        log::error!(
171                            target:"i18n_embed::fluent",
172                            "Failed to format a message for language \"{}\" and id \"{}\".\nErrors\n{:?}.",
173                            inner.current_languages.languages.first().unwrap_or(&self.fallback_language), message_id, errors
174                        )
175                    }
176                    value.into()
177                })
178            )
179            .unwrap_or_else(|| {
180                log::error!(
181                    target:"i18n_embed::fluent",
182                    "Unable to find localization for language \"{}\" and id \"{}\".",
183                    inner.current_languages.languages.first().unwrap_or(&self.fallback_language),
184                    message_id
185                );
186                format!("No localization for id: \"{}\"", message_id)
187            })
188    }
189
190    /// Get a localized message referenced by the `message_id`, and
191    /// formatted with the specified `args`.
192    pub fn get_args<'a, S, V>(&self, id: &str, args: HashMap<S, V>) -> String
193    where
194        S: Into<Cow<'a, str>> + Clone,
195        V: Into<FluentValue<'a>> + Clone,
196    {
197        self.get_args_fluent(id, hash_map_to_fluent_args(args).as_ref())
198    }
199
200    /// Get a localized attribute referenced by the `message_id` and `attribute_id`.
201    pub fn get_attr(&self, message_id: &str, attribute_id: &str) -> String {
202        self.get_attr_args_fluent(message_id, attribute_id, None)
203    }
204
205    /// A non-generic version of [FluentLanguageLoader::get_attr_args()].
206    pub fn get_attr_args_concrete<'args>(
207        &self,
208        message_id: &str,
209        attribute_id: &str,
210        args: HashMap<&'args str, FluentValue<'args>>,
211    ) -> String {
212        self.get_attr_args_fluent(
213            message_id,
214            attribute_id,
215            hash_map_to_fluent_args(args).as_ref(),
216        )
217    }
218
219    /// A non-generic version of [FluentLanguageLoader::get_attr_args()]
220    /// accepting [FluentArgs] instead of a [HashMap].
221    pub fn get_attr_args_fluent<'args>(
222        &self,
223        message_id: &str,
224        attribute_id: &str,
225        args: Option<&'args FluentArgs<'args>>,
226    ) -> String {
227        let inner = self.inner.load();
228        let language_config = inner.language_config.read();
229        let current_language = self.current_language_impl(&inner);
230
231        language_config.language_bundles.iter()
232            .flat_map(|language_bundles| language_bundles.iter())
233            .find_map(|language_bundle| {
234            language_bundle
235                .bundle
236                .get_message(message_id)
237                .and_then(|m: FluentMessage<'_>| {
238                    m.get_attribute(attribute_id)
239                    .map(|a: FluentAttribute<'_>| {
240                        a.value()
241                    })
242                })
243                .map(|pattern: &Pattern<&str>| {
244                    let mut errors = Vec::new();
245                    let value = language_bundle.bundle.format_pattern(pattern, args, &mut errors);
246                    if !errors.is_empty() {
247                        log::error!(
248                            target:"i18n_embed::fluent",
249                            "Failed to format a message for language \"{}\" and id \"{}\".\nErrors\n{:?}.",
250                            current_language, message_id, errors
251                        )
252                    }
253                    value.into()
254                })
255        })
256        .unwrap_or_else(|| {
257            log::error!(
258                target:"i18n_embed::fluent",
259                "Unable to find localization for language \"{}\", message id \"{}\" and attribute id \"{}\".",
260                current_language,
261                message_id,
262                attribute_id
263            );
264            format!("No localization for message id: \"{message_id}\" and attribute id: \"{attribute_id}\"")
265        })
266    }
267
268    /// Get a localized attribute referenced by the `message_id` and `attribute_id`, and
269    /// formatted with the specified `args`.
270    pub fn get_attr_args<'a, S, V>(
271        &self,
272        message_id: &str,
273        attribute_id: &str,
274        args: HashMap<S, V>,
275    ) -> String
276    where
277        S: Into<Cow<'a, str>> + Clone,
278        V: Into<FluentValue<'a>> + Clone,
279    {
280        self.get_attr_args_fluent(
281            message_id,
282            attribute_id,
283            hash_map_to_fluent_args(args).as_ref(),
284        )
285    }
286
287    /// available in any of the languages currently loaded (including
288    /// the fallback language).
289    pub fn has(&self, message_id: &str) -> bool {
290        self.inner
291            .load()
292            .language_config
293            .read()
294            .language_bundles
295            .iter()
296            .flat_map(|language_bundles| language_bundles.iter())
297            .any(|language_bundle| language_bundle.bundle.has_message(message_id))
298    }
299
300    /// Determines if an attribute associated with the specified `message_id`
301    /// is available in any of the currently loaded languages, including the fallback language.
302    ///
303    /// Returns true if at least one available instance was found,
304    /// false otherwise.
305    ///
306    /// Note that this also returns false if the `message_id` could not be found;
307    /// use [FluentLanguageLoader::has()] to determine if the `message_id` is available.
308    pub fn has_attr(&self, message_id: &str, attribute_id: &str) -> bool {
309        self.inner
310            .load()
311            .language_config
312            .read()
313            .language_bundles
314            .iter()
315            .flat_map(|bundles| bundles.iter())
316            .find_map(|bundle| {
317                bundle
318                    .bundle
319                    .get_message(message_id)
320                    .map(|message| message.get_attribute(attribute_id).is_some())
321            })
322            .unwrap_or(false)
323    }
324
325    /// Run the `closure` with the message that matches the specified
326    /// `message_id` (if it is available in any of the languages
327    /// currently loaded, including the fallback language). Returns
328    /// `Some` of whatever whatever the closure returns, or `None` if
329    /// no messages were found matching the `message_id`.
330    pub fn with_fluent_message<OUT, C>(&self, message_id: &str, closure: C) -> Option<OUT>
331    where
332        C: Fn(fluent::FluentMessage<'_>) -> OUT,
333    {
334        self.inner
335            .load()
336            .language_config
337            .read()
338            .language_bundles
339            .iter()
340            .flat_map(|language_bundles| language_bundles.iter())
341            .find_map(|language_bundle| language_bundle.bundle.get_message(message_id))
342            .map(closure)
343    }
344
345    /// Searches for a message named `message_id` in all languages that
346    /// are currently loaded, including the fallback language. If the
347    /// message is found, invokes the `closure` with the:
348    ///
349    /// 0. [message](FluentMessage)
350    /// 1. the language-specific [bundle](FluentBundle)
351    ///    that owns it.
352    ///
353    /// Returns `Some` of whatever the closure returns, or `None` if no
354    /// messages were found matching the `message_id`.
355    pub fn with_fluent_message_and_bundle<OUT, C>(
356        &self,
357        message_id: &str,
358        closure: C,
359    ) -> Option<OUT>
360    where
361        C: Fn(FluentMessage<'_>, &FluentBundle<Arc<FluentResource>, IntlLangMemoizer>) -> OUT,
362    {
363        self.inner
364            .load()
365            .language_config
366            .read()
367            .language_bundles
368            .iter()
369            .flat_map(|language_bundles| language_bundles.iter())
370            .find_map(|language_bundle| {
371                Some((
372                    language_bundle.bundle.get_message(message_id)?,
373                    &language_bundle.bundle,
374                ))
375            })
376            .map(|(msg, bundle)| closure(msg, bundle))
377    }
378
379    /// Runs the provided `closure` with an iterator over the messages
380    /// available for the specified `language`. There may be duplicate
381    /// messages when they are duplicated in resources applicable to
382    /// the language. Returns the result of the closure.
383    pub fn with_message_iter<OUT, C>(&self, language: &LanguageIdentifier, closure: C) -> OUT
384    where
385        C: Fn(&mut dyn Iterator<Item = &ast::Message<&str>>) -> OUT,
386    {
387        let inner = self.inner.load();
388        let config_lock = inner.language_config.read();
389
390        let mut iter = config_lock
391            .language_bundles
392            .iter()
393            .flat_map(|language_bundles| language_bundles.iter())
394            .filter(|language_bundle| &language_bundle.language == language)
395            .flat_map(|language_bundle| {
396                language_bundle
397                    .resource
398                    .entries()
399                    .filter_map(|entry| match entry {
400                        ast::Entry::Message(message) => Some(message),
401                        _ => None,
402                    })
403            });
404
405        (closure)(&mut iter)
406    }
407
408    /// Set whether the underlying Fluent logic should insert Unicode
409    /// Directionality Isolation Marks around placeables.
410    ///
411    /// See [`fluent::bundle::FluentBundleBase::set_use_isolating`] for more
412    /// information.
413    ///
414    /// **Note:** This function will have no effect if
415    /// [`LanguageLoader::load_languages`] has not been called first.
416    ///
417    /// Default: `true`.
418    pub fn set_use_isolating(&self, value: bool) {
419        self.with_bundles_mut(|bundle| bundle.set_use_isolating(value));
420    }
421
422    /// Apply some configuration to each budle in this loader.
423    ///
424    /// **Note:** This function will have no effect if
425    /// [`LanguageLoader::load_languages`] has not been called first.
426    pub fn with_bundles_mut<F>(&self, f: F)
427    where
428        F: Fn(&mut FluentBundle<Arc<FluentResource>, IntlLangMemoizer>),
429    {
430        for bundle in self
431            .inner
432            .load()
433            .language_config
434            .write()
435            .language_bundles
436            .iter_mut()
437            .flat_map(|bundles| bundles.iter_mut())
438        {
439            f(&mut bundle.bundle);
440        }
441    }
442
443    /// Create a new loader with a subset of currently loaded languages.
444    /// This is a rather cheap operation and does not require any
445    /// extensive copy operations. Cheap does not mean free so you
446    /// should not call this message repeatedly in order to translate
447    /// multiple strings for the same language.
448    pub fn select_languages<LI: AsRef<LanguageIdentifier>>(
449        &self,
450        languages: &[LI],
451    ) -> FluentLanguageLoader {
452        let inner = self.inner.load();
453        let config_lock = inner.language_config.read();
454        let fallback_language: Option<&unic_langid::LanguageIdentifier> = if languages
455            .iter()
456            .any(|language| language.as_ref() == &self.fallback_language)
457        {
458            None
459        } else {
460            Some(&self.fallback_language)
461        };
462
463        let indices = languages
464            .iter()
465            .map(|lang| lang.as_ref())
466            .chain(fallback_language)
467            .filter_map(|lang| config_lock.language_map.get(lang.as_ref()))
468            .cloned()
469            .collect();
470        FluentLanguageLoader {
471            inner: ArcSwap::new(Arc::new(FluentLanguageLoaderInner {
472                current_languages: CurrentLanguages {
473                    languages: languages.iter().map(|lang| lang.as_ref().clone()).collect(),
474                    indices,
475                },
476                language_config: self.inner.load().language_config.clone(),
477            })),
478            domain: self.domain.clone(),
479            fallback_language: self.fallback_language.clone(),
480        }
481    }
482
483    /// Select the requested `languages` from the currently loaded languages using the supplied
484    /// [`NegotiationStrategy`].
485    pub fn select_languages_negotiate<LI: AsRef<LanguageIdentifier>>(
486        &self,
487        languages: &[LI],
488        strategy: NegotiationStrategy,
489    ) -> FluentLanguageLoader {
490        let available_languages = &self.inner.load().current_languages.languages;
491        let negotiated_languages = fluent_langneg::negotiate_languages(
492            languages,
493            available_languages,
494            Some(self.fallback_language()),
495            strategy,
496        );
497
498        self.select_languages(&negotiated_languages)
499    }
500}
501
502impl LanguageLoader for FluentLanguageLoader {
503    /// The fallback language for the module this loader is responsible
504    /// for.
505    fn fallback_language(&self) -> &unic_langid::LanguageIdentifier {
506        &self.fallback_language
507    }
508    /// The domain for the translation that this loader is associated with.
509    fn domain(&self) -> &str {
510        &self.domain
511    }
512
513    /// The language file name to use for this loader.
514    fn language_file_name(&self) -> String {
515        format!("{}.ftl", self.domain())
516    }
517
518    /// Get the language which is currently selected for this loader.
519    fn current_language(&self) -> unic_langid::LanguageIdentifier {
520        self.current_language_impl(&self.inner.load())
521    }
522
523    /// Load the languages `language_ids` using the resources packaged
524    /// in the `i18n_assets` in order of fallback preference. This
525    /// also sets the [LanguageLoader::current_language()] to the
526    /// first in the `language_ids` slice. You can use
527    /// [select()](super::select()) to determine which fallbacks are
528    /// actually available for an arbitrary slice of preferences.
529    #[allow(single_use_lifetimes)]
530    fn load_languages<'a>(
531        &self,
532        i18n_assets: &dyn I18nAssets,
533        language_ids: &[unic_langid::LanguageIdentifier],
534    ) -> Result<(), I18nEmbedError> {
535        let mut language_ids = language_ids.iter().peekable();
536        if language_ids.peek().is_none() {
537            return Err(I18nEmbedError::RequestedLanguagesEmpty);
538        }
539
540        // The languages to load
541        let language_ids: Vec<unic_langid::LanguageIdentifier> =
542            language_ids.map(|id| (*id).clone()).collect();
543        let mut load_language_ids: Vec<unic_langid::LanguageIdentifier> = language_ids.clone();
544
545        if !load_language_ids.contains(&self.fallback_language) {
546            load_language_ids.push(self.fallback_language.clone());
547        }
548        let language_bundles: Vec<Vec<_>> = load_language_ids.iter().map(|language| {
549            let (path, files) = self.language_files(language, i18n_assets);
550
551            if files.is_empty() {
552                log::debug!(target:"i18n_embed::fluent", "Unable to find language file: \"{0}\" for language: \"{1}\"", path, language);
553                if language == &self.fallback_language {
554                    return Err(I18nEmbedError::LanguageNotAvailable(path, language.clone()));
555                }
556            }
557            files.into_iter().map(|file| {
558                log::debug!(target:"i18n_embed::fluent", "Loaded language file: \"{0}\" for language: \"{1}\"", path, language);
559
560                let file_string = String::from_utf8(file.to_vec())
561                    .map_err(|err| I18nEmbedError::ErrorParsingFileUtf8(path.clone(), err))?
562                    // TODO: Workaround for https://github.com/kellpossible/cargo-i18n/issues/57
563                    // remove when https://github.com/projectfluent/fluent-rs/issues/213 is resolved.
564                    .replace("\u{000D}\n", "\n");
565
566                let resource = match FluentResource::try_new(file_string) {
567                    Ok(resource) => resource,
568                    Err((resource, errors)) => {
569                        errors.iter().for_each(|err| {
570                            log::error!(target: "i18n_embed::fluent", "Error while parsing fluent language file \"{0}\": \"{1:?}\".", path, err);
571                        });
572                        resource
573                    }
574                };
575
576                Ok(LanguageBundle::new(language.clone(), resource))
577            }).collect::<Result<Vec<_>, I18nEmbedError>>()
578        }).collect::<Result<_, I18nEmbedError>>()?;
579
580        self.inner.swap(Arc::new(FluentLanguageLoaderInner {
581            current_languages: CurrentLanguages {
582                languages: language_ids,
583                indices: (0..load_language_ids.len()).collect(),
584            },
585            language_config: Arc::new(RwLock::new(LanguageConfig {
586                language_map: language_bundles
587                    .iter()
588                    .enumerate()
589                    .map(|(i, language_bundles)| {
590                        (
591                            language_bundles.first().expect("Expect there to be at least bundle in a set of bundles per language").language.clone(),
592                            i
593                        )
594                    })
595                    .collect(),
596                language_bundles,
597            })),
598        }));
599
600        Ok(())
601    }
602
603    fn reload(&self, i18n_assets: &dyn I18nAssets) -> Result<(), I18nEmbedError> {
604        self.load_languages(
605            i18n_assets,
606            &self.inner.load().current_languages.languages.clone(),
607        )
608    }
609}
610
611fn hash_map_to_fluent_args<'args, K, V>(map: HashMap<K, V>) -> Option<FluentArgs<'args>>
612where
613    K: Into<Cow<'args, str>>,
614    V: Into<FluentValue<'args>>,
615{
616    if map.is_empty() {
617        None
618    } else {
619        Some(FluentArgs::from_iter(map))
620    }
621}