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, ®ion_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}