Skip to main content

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 crate::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    /// Create a [`Locale`] from a static string containing Fluent (`.ftl`) content.
25    ///
26    /// Typically used with [`include_str!`] to embed locale files at compile time.
27    pub fn new_static(id: LanguageIdentifier, str: &'static str) -> Self {
28        Self {
29            id,
30            resource: LocaleResource::Static(str),
31        }
32    }
33
34    /// Create a [`Locale`] from a filesystem path to a Fluent (`.ftl`) file.
35    ///
36    /// The file will be read at runtime when the locale is loaded into a bundle.
37    pub fn new_dynamic(id: LanguageIdentifier, path: impl Into<PathBuf>) -> Self {
38        Self {
39            id,
40            resource: LocaleResource::Path(path.into()),
41        }
42    }
43}
44
45impl<T> From<(LanguageIdentifier, T)> for Locale
46where
47    T: Into<LocaleResource>,
48{
49    fn from((id, resource): (LanguageIdentifier, T)) -> Self {
50        let resource = resource.into();
51        Self { id, resource }
52    }
53}
54
55/// A `LocaleResource` can be static text, or a filesystem file.
56#[derive(Debug, PartialEq)]
57pub enum LocaleResource {
58    Static(&'static str),
59
60    Path(PathBuf),
61}
62
63impl LocaleResource {
64    pub fn try_to_resource_string(&self) -> Result<String, Error> {
65        match self {
66            Self::Static(str) => Ok(str.to_string()),
67
68            Self::Path(path) => std::fs::read_to_string(path)
69                .map_err(|e| Error::LocaleResourcePathReadFailed(e.to_string())),
70        }
71    }
72
73    pub fn to_resource_string(&self) -> String {
74        let result = self.try_to_resource_string();
75        match result {
76            Ok(string) => string,
77            Err(err) => panic!("failed to create resource string {self:?}: {err}"),
78        }
79    }
80}
81
82impl From<&'static str> for LocaleResource {
83    fn from(value: &'static str) -> Self {
84        Self::Static(value)
85    }
86}
87
88impl From<PathBuf> for LocaleResource {
89    fn from(value: PathBuf) -> Self {
90        Self::Path(value)
91    }
92}
93
94/// The configuration for `I18n`.
95#[derive(Debug, PartialEq)]
96pub struct I18nConfig {
97    /// The initial language, can be later changed with [`I18n::set_language`]
98    pub id: LanguageIdentifier,
99
100    /// The final fallback language if no other locales are found for `id`.
101    /// A `Locale` must exist in `locales' if `fallback` is defined.
102    pub fallback: Option<LanguageIdentifier>,
103
104    /// The locale_resources added to the configuration.
105    pub locale_resources: Vec<LocaleResource>,
106
107    /// The locales added to the configuration.
108    pub locales: HashMap<LanguageIdentifier, usize>,
109}
110
111impl I18nConfig {
112    /// Create an i18n config with the selected [LanguageIdentifier].
113    pub fn new(id: LanguageIdentifier) -> Self {
114        Self {
115            id,
116            fallback: None,
117            locale_resources: Vec::new(),
118            locales: HashMap::new(),
119        }
120    }
121
122    /// Set a fallback [LanguageIdentifier].
123    pub fn with_fallback(mut self, fallback: LanguageIdentifier) -> Self {
124        self.fallback = Some(fallback);
125        self
126    }
127
128    /// Add [Locale].
129    /// It is possible to share locales resources. If this locale's resource
130    /// matches a previously added one, then this locale will use the existing one.
131    /// This is primarily for the static locale_resources to avoid string duplication.
132    pub fn with_locale<T>(mut self, locale: T) -> Self
133    where
134        T: Into<Locale>,
135    {
136        let locale = locale.into();
137        let locale_resources_len = self.locale_resources.len();
138
139        let index = self
140            .locale_resources
141            .iter()
142            .position(|r| *r == locale.resource)
143            .unwrap_or(locale_resources_len);
144
145        if index == locale_resources_len {
146            self.locale_resources.push(locale.resource)
147        };
148
149        self.locales.insert(locale.id, index);
150        self
151    }
152
153    /// Add multiple locales from given folder, based on their filename.
154    ///
155    /// If the path represents a folder, then the folder will be deep traversed for
156    /// all '*.ftl' files. If the filename represents a [LanguageIdentifier] then it
157    ///  will be added to the config.
158    ///
159    /// If the path represents a file, then the filename must represent a
160    /// unic_langid::LanguageIdentifier for it to be added to the config.
161    #[cfg(feature = "discovery")]
162    pub fn try_with_auto_locales(self, path: PathBuf) -> Result<Self, Error> {
163        if path.is_dir() {
164            let files = find_ftl_files(&path)?;
165            files
166                .into_iter()
167                .try_fold(self, |acc, file| acc.with_auto_pathbuf(file))
168        } else if is_ftl_file(&path) {
169            self.with_auto_pathbuf(path)
170        } else {
171            Err(Error::InvalidPath(path.to_string_lossy().to_string()))
172        }
173    }
174
175    #[cfg(feature = "discovery")]
176    fn with_auto_pathbuf(self, file: PathBuf) -> Result<Self, Error> {
177        assert!(is_ftl_file(&file));
178
179        let stem = file.file_stem().ok_or_else(|| {
180            Error::InvalidLanguageId(format!("No file stem: '{}'", file.display()))
181        })?;
182
183        let id_str = stem.to_str().ok_or_else(|| {
184            Error::InvalidLanguageId(format!("Cannot convert: {}", stem.to_string_lossy()))
185        })?;
186
187        let id = LanguageIdentifier::from_bytes(id_str.as_bytes())
188            .map_err(|e| Error::InvalidLanguageId(e.to_string()))?;
189
190        Ok(self.with_locale((id, file)))
191    }
192
193    /// Add multiple locales from given folder, based on their filename.
194    ///
195    /// Will panic! on error.
196    #[cfg(feature = "discovery")]
197    pub fn with_auto_locales(self, path: PathBuf) -> Self {
198        let path_name = path.display().to_string();
199        let result = self.try_with_auto_locales(path);
200        match result {
201            Ok(result) => result,
202            Err(err) => panic!("with_auto_locales must have valid pathbuf {path_name}: {err}",),
203        }
204    }
205}
206
207#[cfg(feature = "discovery")]
208fn find_ftl_files(folder: &PathBuf) -> Result<Vec<PathBuf>, Error> {
209    let ftl_files: Vec<PathBuf> = walkdir::WalkDir::new(folder)
210        .into_iter()
211        .filter_map(|entry| entry.ok())
212        .filter(|entry| is_ftl_file(entry.path()))
213        .map(|entry| entry.path().to_path_buf())
214        .collect();
215
216    Ok(ftl_files)
217}
218
219#[cfg(feature = "discovery")]
220fn is_ftl_file(entry: &std::path::Path) -> bool {
221    entry.is_file() && entry.extension().map(|ext| ext == "ftl").unwrap_or(false)
222}
223
224/// Provide an existing [`I18n`] instance to descendant components.
225///
226/// This is useful for sharing the same global i18n state across different parts of the
227/// component tree or across multiple windows. Typically paired with [`I18n::create_global`].
228///
229/// ```rust,no_run
230/// # use freya::prelude::*;
231/// # use freya::i18n::*;
232/// struct MyApp {
233///     i18n: I18n,
234/// }
235///
236/// impl App for MyApp {
237///     fn render(&self) -> impl IntoElement {
238///         // Make the I18n instance available to all descendant components
239///         use_share_i18n(move || self.i18n);
240///
241///         rect().child(t!("hello_world"))
242///     }
243/// }
244/// ```
245pub fn use_share_i18n(i18n: impl FnOnce() -> I18n) {
246    use_provide_context(i18n);
247}
248
249/// Initialize an [`I18n`] instance and provide it to descendant components.
250///
251/// This is the recommended way to set up i18n in your application. Call it once in a root
252/// component and then use [`I18n::get`] (or the `t!`, `te!`, `tid!` macros) in any
253/// descendant component to access translations.
254///
255/// See [`I18n::create`] for a manual initialization where you can also handle errors.
256///
257/// # Panics
258///
259/// Panics if the [`I18nConfig`] fails to produce a valid Fluent bundle.
260///
261/// ```rust
262/// # use freya::prelude::*;
263/// # use freya::i18n::*;
264/// fn app() -> impl IntoElement {
265///     let mut i18n = use_init_i18n(|| {
266///         I18nConfig::new(langid!("en-US"))
267///             .with_locale(Locale::new_static(
268///                 langid!("en-US"),
269///                 include_str!("../../../examples/i18n/en-US.ftl"),
270///             ))
271///             .with_locale(Locale::new_dynamic(
272///                 langid!("es-ES"),
273///                 "../../../examples/i18n/es-ES.ftl",
274///             ))
275///     });
276///
277///     let change_to_spanish = move |_| i18n.set_language(langid!("es-ES"));
278///
279///     rect()
280///         .child(t!("hello_world"))
281///         .child(Button::new().on_press(change_to_spanish).child("Spanish"))
282/// }
283/// ```
284pub fn use_init_i18n(init: impl FnOnce() -> I18nConfig) -> I18n {
285    use_provide_context(move || {
286        // Coverage false -ve: See https://github.com/xd009642/tarpaulin/issues/1675
287        match I18n::create(init()) {
288            Ok(i18n) => i18n,
289            Err(e) => panic!("Failed to create I18n context: {e}"),
290        }
291    })
292}
293
294/// The main handle for accessing and managing internationalization state.
295///
296/// `I18n` holds the selected language, fallback language, locale resources, and the active
297/// [Fluent](https://projectfluent.org/) bundle used for translating messages. It is `Clone + Copy`
298/// so it can be freely passed around in components.
299///
300/// There are several ways to obtain an `I18n` instance:
301/// - [`use_init_i18n`] to create and provide it to descendant components.
302/// - [`I18n::create`] to manually create one from an [`I18nConfig`] (useful when you need to handle errors).
303/// - [`I18n::create_global`] to create one with global lifetime, suitable for multi-window apps.
304/// - [`I18n::get`] or [`I18n::try_get`] to retrieve an already-provided instance from the component context.
305/// - [`use_share_i18n`] to re-provide an existing instance to a different part of the component tree.
306#[derive(Clone, Copy)]
307pub struct I18n {
308    selected_language: State<LanguageIdentifier>,
309    fallback_language: State<Option<LanguageIdentifier>>,
310    locale_resources: State<Vec<LocaleResource>>,
311    locales: State<HashMap<LanguageIdentifier, usize>>,
312    active_bundle: State<FluentBundle<FluentResource>>,
313}
314
315impl I18n {
316    /// Try to retrieve the [`I18n`] instance from the component context.
317    ///
318    /// Returns `None` if no [`I18n`] has been provided via [`use_init_i18n`] or [`use_share_i18n`]
319    /// in an ancestor component.
320    pub fn try_get() -> Option<Self> {
321        try_consume_context()
322    }
323
324    /// Retrieve the [`I18n`] instance from the component context.
325    ///
326    /// This is the primary way to access the i18n state from within a component that is a
327    /// descendant of a component that called [`use_init_i18n`] or [`use_share_i18n`].
328    ///
329    /// # Panics
330    ///
331    /// Panics if no [`I18n`] has been provided in an ancestor component.
332    ///
333    /// ```rust
334    /// # use freya::prelude::*;
335    /// # use freya::i18n::*;
336    /// #[derive(PartialEq)]
337    /// struct MyComponent;
338    ///
339    /// impl Component for MyComponent {
340    ///     fn render(&self) -> impl IntoElement {
341    ///         let mut i18n = I18n::get();
342    ///
343    ///         let change_to_english = move |_| i18n.set_language(langid!("en-US"));
344    ///
345    ///         rect()
346    ///             .child(t!("hello_world"))
347    ///             .child(Button::new().on_press(change_to_english).child("English"))
348    ///     }
349    /// }
350    /// ```
351    pub fn get() -> Self {
352        consume_context()
353    }
354
355    /// Manually create an [`I18n`] instance from an [`I18nConfig`].
356    ///
357    /// Unlike [`use_init_i18n`], this does not automatically provide the instance to descendant
358    /// components. Use [`use_share_i18n`] to share it afterwards, or call this when you need
359    /// explicit error handling during initialization.
360    ///
361    /// The created state is scoped to the current component. For global state that outlives
362    /// any single component, see [`I18n::create_global`].
363    pub fn create(
364        I18nConfig {
365            id: selected_language,
366            fallback: fallback_language,
367            locale_resources,
368            locales,
369        }: I18nConfig,
370    ) -> Result<Self, Error> {
371        let bundle = try_create_bundle(
372            &selected_language,
373            &fallback_language,
374            &locale_resources,
375            &locales,
376        )?;
377        Ok(Self {
378            selected_language: State::create(selected_language),
379            fallback_language: State::create(fallback_language),
380            locale_resources: State::create(locale_resources),
381            locales: State::create(locales),
382            active_bundle: State::create(bundle),
383        })
384    }
385
386    /// Create an [`I18n`] instance with global lifetime.
387    ///
388    /// Unlike [`I18n::create`], the state created here is not scoped to any component and will
389    /// live for the entire duration of the application. This is useful for multi-window apps
390    /// where i18n state needs to be created in `main` and then shared across different windows
391    /// via [`use_share_i18n`].
392    ///
393    /// This is **not** a hook, do not use it inside components like you would [`use_init_i18n`].
394    /// You would usually want to call this in your `main` function.
395    ///
396    /// ```rust,no_run
397    /// # use freya::prelude::*;
398    /// # use freya::i18n::*;
399    /// struct MyApp {
400    ///     i18n: I18n,
401    /// }
402    ///
403    /// impl App for MyApp {
404    ///     fn render(&self) -> impl IntoElement {
405    ///         // Re-provide the global I18n to this window's component tree
406    ///         use_share_i18n(move || self.i18n);
407    ///
408    ///         rect().child(t!("hello_world"))
409    ///     }
410    /// }
411    ///
412    /// fn main() {
413    ///     // Create I18n with global lifetime in main, before any window is opened
414    ///     let i18n = I18n::create_global(I18nConfig::new(langid!("en-US")).with_locale((
415    ///         langid!("en-US"),
416    ///         include_str!("../../../examples/i18n/en-US.ftl"),
417    ///     )))
418    ///     .expect("Failed to create I18n");
419    ///
420    ///     // Pass it to each window's app struct
421    ///     launch(LaunchConfig::new().with_window(WindowConfig::new_app(MyApp { i18n })))
422    /// }
423    /// ```
424    pub fn create_global(
425        I18nConfig {
426            id: selected_language,
427            fallback: fallback_language,
428            locale_resources,
429            locales,
430        }: I18nConfig,
431    ) -> Result<Self, Error> {
432        let bundle = try_create_bundle(
433            &selected_language,
434            &fallback_language,
435            &locale_resources,
436            &locales,
437        )?;
438        Ok(Self {
439            selected_language: State::create_global(selected_language),
440            fallback_language: State::create_global(fallback_language),
441            locale_resources: State::create_global(locale_resources),
442            locales: State::create_global(locales),
443            active_bundle: State::create_global(bundle),
444        })
445    }
446
447    /// Translate a message by its identifier, optionally with Fluent arguments.
448    ///
449    /// The `msg` can be a simple message id (e.g. `"hello"`) or a dotted attribute
450    /// id (e.g. `"my_component.placeholder"`). See [`I18n::decompose_identifier`] for details.
451    ///
452    /// Returns an error if the message id is not found, the pattern is missing,
453    /// or Fluent reports errors during formatting.
454    ///
455    /// Prefer the `t!`, `te!`, or `tid!` macros for ergonomic translations.
456    pub fn try_translate_with_args(
457        &self,
458        msg: &str,
459        args: Option<&FluentArgs>,
460    ) -> Result<String, Error> {
461        let (message_id, attribute_name) = Self::decompose_identifier(msg)?;
462
463        let bundle = self.active_bundle.read();
464
465        let message = bundle
466            .get_message(message_id)
467            .ok_or_else(|| Error::MessageIdNotFound(message_id.into()))?;
468
469        let pattern = if let Some(attribute_name) = attribute_name {
470            let attribute = message
471                .get_attribute(attribute_name)
472                .ok_or_else(|| Error::AttributeIdNotFound(msg.to_string()))?;
473            attribute.value()
474        } else {
475            message
476                .value()
477                .ok_or_else(|| Error::MessagePatternNotFound(message_id.into()))?
478        };
479
480        let mut errors = vec![];
481        let translation = bundle
482            .format_pattern(pattern, args, &mut errors)
483            .to_string();
484
485        (errors.is_empty())
486            .then_some(translation)
487            .ok_or_else(|| Error::FluentErrorsDetected(format!("{errors:#?}")))
488    }
489
490    /// Split a message identifier into its message id and optional attribute name.
491    ///
492    /// - `"hello"` returns `("hello", None)`
493    /// - `"my_component.placeholder"` returns `("my_component", Some("placeholder"))`
494    ///
495    /// Returns an error if the identifier contains more than one dot.
496    pub fn decompose_identifier(msg: &str) -> Result<(&str, Option<&str>), Error> {
497        let parts: Vec<&str> = msg.split('.').collect();
498        match parts.as_slice() {
499            [message_id] => Ok((message_id, None)),
500            [message_id, attribute_name] => Ok((message_id, Some(attribute_name))),
501            _ => Err(Error::InvalidMessageId(msg.to_string())),
502        }
503    }
504
505    /// Translate a message by its identifier, optionally with Fluent arguments.
506    ///
507    /// This is the panicking version of [`I18n::try_translate_with_args`].
508    ///
509    /// # Panics
510    ///
511    /// Panics if the translation fails for any reason.
512    pub fn translate_with_args(&self, msg: &str, args: Option<&FluentArgs>) -> String {
513        let result = self.try_translate_with_args(msg, args);
514        match result {
515            Ok(translation) => translation,
516            Err(err) => panic!("Failed to translate {msg}: {err}"),
517        }
518    }
519
520    /// Translate a message by its identifier, without arguments.
521    ///
522    /// Shorthand for `self.try_translate_with_args(msg, None)`.
523    #[inline]
524    pub fn try_translate(&self, msg: &str) -> Result<String, Error> {
525        self.try_translate_with_args(msg, None)
526    }
527
528    /// Translate a message by its identifier, without arguments.
529    ///
530    /// This is the panicking version of [`I18n::try_translate`].
531    ///
532    /// # Panics
533    ///
534    /// Panics if the translation fails for any reason.
535    pub fn translate(&self, msg: &str) -> String {
536        let result = self.try_translate(msg);
537        match result {
538            Ok(translation) => translation,
539            Err(err) => panic!("Failed to translate {msg}: {err}"),
540        }
541    }
542
543    /// Get the selected language.
544    #[inline]
545    pub fn language(&self) -> LanguageIdentifier {
546        self.selected_language.read().clone()
547    }
548
549    /// Get the fallback language.
550    pub fn fallback_language(&self) -> Option<LanguageIdentifier> {
551        self.fallback_language.read().clone()
552    }
553
554    /// Update the selected language, rebuilding the active Fluent bundle.
555    ///
556    /// Returns an error if the bundle cannot be rebuilt for the new language.
557    pub fn try_set_language(&mut self, id: LanguageIdentifier) -> Result<(), Error> {
558        *self.selected_language.write() = id;
559        self.try_update_active_bundle()
560    }
561
562    /// Update the selected language, rebuilding the active Fluent bundle.
563    ///
564    /// This is the panicking version of [`I18n::try_set_language`].
565    ///
566    /// # Panics
567    ///
568    /// Panics if the bundle cannot be rebuilt for the new language.
569    pub fn set_language(&mut self, id: LanguageIdentifier) {
570        let id_name = id.to_string();
571        let result = self.try_set_language(id);
572        match result {
573            Ok(()) => (),
574            Err(err) => panic!("cannot set language {id_name}: {err}"),
575        }
576    }
577
578    /// Update the fallback language, rebuilding the active Fluent bundle.
579    ///
580    /// The given language must have a corresponding [`Locale`] registered in the config.
581    /// Returns an error if no locale exists for the language or if the bundle cannot be rebuilt.
582    pub fn try_set_fallback_language(&mut self, id: LanguageIdentifier) -> Result<(), Error> {
583        self.locales
584            .read()
585            .get(&id)
586            .ok_or_else(|| Error::FallbackMustHaveLocale(id.to_string()))?;
587
588        *self.fallback_language.write() = Some(id);
589        self.try_update_active_bundle()
590    }
591
592    /// Update the fallback language, rebuilding the active Fluent bundle.
593    ///
594    /// This is the panicking version of [`I18n::try_set_fallback_language`].
595    ///
596    /// # Panics
597    ///
598    /// Panics if no locale exists for the language or if the bundle cannot be rebuilt.
599    pub fn set_fallback_language(&mut self, id: LanguageIdentifier) {
600        let id_name = id.to_string();
601        let result = self.try_set_fallback_language(id);
602        match result {
603            Ok(()) => (),
604            Err(err) => panic!("cannot set fallback language {id_name}: {err}"),
605        }
606    }
607
608    fn try_update_active_bundle(&mut self) -> Result<(), Error> {
609        let bundle = try_create_bundle(
610            &self.selected_language.peek(),
611            &self.fallback_language.peek(),
612            &self.locale_resources.peek(),
613            &self.locales.peek(),
614        )?;
615
616        self.active_bundle.set(bundle);
617        Ok(())
618    }
619}
620
621fn try_create_bundle(
622    selected_language: &LanguageIdentifier,
623    fallback_language: &Option<LanguageIdentifier>,
624    locale_resources: &[LocaleResource],
625    locales: &HashMap<LanguageIdentifier, usize>,
626) -> Result<FluentBundle<FluentResource>, Error> {
627    let add_resource = move |bundle: &mut FluentBundle<FluentResource>,
628                             langid: &LanguageIdentifier,
629                             locale_resources: &[LocaleResource]| {
630        if let Some(&i) = locales.get(langid) {
631            let resource = &locale_resources[i];
632            let resource =
633                FluentResource::try_new(resource.try_to_resource_string()?).map_err(|e| {
634                    Error::FluentErrorsDetected(format!("resource langid: {langid}\n{e:#?}"))
635                })?;
636            bundle.add_resource_overriding(resource);
637        };
638        Ok(())
639    };
640
641    let mut bundle = FluentBundle::new(vec![selected_language.clone()]);
642    if let Some(fallback_language) = fallback_language {
643        add_resource(&mut bundle, fallback_language, locale_resources)?;
644    }
645
646    let (language, script, region, variants) = selected_language.clone().into_parts();
647    let variants_lang = LanguageIdentifier::from_parts(language, script, region, &variants);
648    let region_lang = LanguageIdentifier::from_parts(language, script, region, &[]);
649    let script_lang = LanguageIdentifier::from_parts(language, script, None, &[]);
650    let language_lang = LanguageIdentifier::from_parts(language, None, None, &[]);
651
652    add_resource(&mut bundle, &language_lang, locale_resources)?;
653    add_resource(&mut bundle, &script_lang, locale_resources)?;
654    add_resource(&mut bundle, &region_lang, locale_resources)?;
655    add_resource(&mut bundle, &variants_lang, locale_resources)?;
656
657    /* Add this code when the fluent crate includes FluentBundle::add_builtins.
658     * This will allow the use of built-in functions like `NUMBER` and `DATETIME`.
659     * See [Fluent issue](https://github.com/projectfluent/fluent-rs/issues/181) for more information.
660    bundle
661        .add_builtins()
662        .map_err(|e| Error::FluentErrorsDetected(e.to_string()))?;
663    */
664
665    Ok(bundle)
666}