Skip to main content

es_fluent_manager_embedded/
lib.rs

1#![doc = include_str!("../README.md")]
2
3use es_fluent::{
4    FluentArgs, FluentLocalizer, FluentLocalizerExt, FluentLocalizerLookup, FluentMessage,
5    registry::StaticFluentMessageKey,
6};
7use es_fluent_manager_core::{FluentManager, ModuleDiscoveryError};
8use std::sync::{Arc, RwLock};
9use tracing::info;
10use unic_langid::LanguageIdentifier;
11
12#[doc(hidden)]
13pub use es_fluent::__inventory;
14
15#[doc(hidden)]
16pub use rust_embed as __rust_embed;
17
18#[doc(hidden)]
19pub use es_fluent_manager_core as __manager_core;
20
21#[doc(hidden)]
22pub use unic_langid as __unic_langid;
23
24#[cfg(feature = "macros")]
25pub use es_fluent_manager_macros::define_embedded_i18n_module as define_i18n_module;
26
27pub use es_fluent_manager_core::LocalizationError;
28
29#[derive(Debug)]
30pub enum EmbeddedInitError {
31    ModuleDiscovery(Vec<ModuleDiscoveryError>),
32    LanguageSelection(LocalizationError),
33}
34
35impl std::fmt::Display for EmbeddedInitError {
36    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
37        match self {
38            Self::ModuleDiscovery(errors) => {
39                f.write_str("failed strict i18n module discovery")?;
40                for error in errors {
41                    write!(f, "\n- {error}")?;
42                }
43                Ok(())
44            },
45            Self::LanguageSelection(error) => {
46                write!(f, "failed to select the requested language: {error}")
47            },
48        }
49    }
50}
51
52impl std::error::Error for EmbeddedInitError {
53    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
54        match self {
55            Self::ModuleDiscovery(_) => None,
56            Self::LanguageSelection(error) => Some(error),
57        }
58    }
59}
60
61#[derive(Clone, Copy, Debug, Eq, PartialEq)]
62enum EmbeddedSelectionPolicy {
63    BestEffort,
64    Strict,
65}
66
67#[derive(Clone, Debug, Eq, PartialEq)]
68struct ActiveSelection {
69    language: LanguageIdentifier,
70    policy: EmbeddedSelectionPolicy,
71}
72
73/// Explicit embedded localization context.
74///
75/// Construct this once during application startup, keep it in application state,
76/// and pass it to code that needs localization.
77#[derive(Clone)]
78pub struct EmbeddedI18n {
79    manager: Arc<FluentManager>,
80    active_selection: Arc<RwLock<Option<ActiveSelection>>>,
81}
82
83impl EmbeddedI18n {
84    fn from_manager(manager: FluentManager) -> Self {
85        Self::from_manager_with_active_selection(manager, None)
86    }
87
88    fn from_manager_with_active_selection(
89        manager: FluentManager,
90        active_selection: Option<ActiveSelection>,
91    ) -> Self {
92        Self {
93            manager: Arc::new(manager),
94            active_selection: Arc::new(RwLock::new(active_selection)),
95        }
96    }
97
98    fn select_language_with_policy(
99        &self,
100        lang: LanguageIdentifier,
101        policy: EmbeddedSelectionPolicy,
102    ) -> Result<(), LocalizationError> {
103        // Keep the cache check, manager transition, and cache commit in one
104        // critical section shared by every clone and both selection policies.
105        let mut active_selection = self
106            .active_selection
107            .write()
108            .unwrap_or_else(|error| error.into_inner());
109        if active_selection
110            .as_ref()
111            .is_some_and(|selection| selection.language == lang && selection.policy == policy)
112        {
113            return Ok(());
114        }
115
116        info!("Changing locale to: {}", lang);
117        match policy {
118            EmbeddedSelectionPolicy::BestEffort => self.manager.select_language(&lang)?,
119            EmbeddedSelectionPolicy::Strict => self.manager.select_language_strict(&lang)?,
120        }
121        #[cfg(test)]
122        tests::after_manager_selection();
123        *active_selection = Some(ActiveSelection {
124            language: lang,
125            policy,
126        });
127        Ok(())
128    }
129
130    /// Builds an embedded context without selecting a language.
131    pub fn try_new() -> Result<Self, EmbeddedInitError> {
132        FluentManager::try_new_with_discovered_modules()
133            .map(Self::from_manager)
134            .map_err(EmbeddedInitError::ModuleDiscovery)
135    }
136
137    /// Builds an embedded context and selects the initial active language.
138    pub fn try_new_with_language<L: Into<LanguageIdentifier>>(
139        lang: L,
140    ) -> Result<Self, EmbeddedInitError> {
141        let lang = lang.into();
142        let manager = FluentManager::try_new_with_discovered_modules()
143            .map_err(EmbeddedInitError::ModuleDiscovery)?;
144        manager
145            .select_language(&lang)
146            .map_err(EmbeddedInitError::LanguageSelection)?;
147        Ok(Self::from_manager_with_active_selection(
148            manager,
149            Some(ActiveSelection {
150                language: lang,
151                policy: EmbeddedSelectionPolicy::BestEffort,
152            }),
153        ))
154    }
155
156    /// Builds an embedded context and selects the initial active language,
157    /// failing if any runtime module rejects the requested locale.
158    pub fn try_new_with_language_strict<L: Into<LanguageIdentifier>>(
159        lang: L,
160    ) -> Result<Self, EmbeddedInitError> {
161        let lang = lang.into();
162        let manager = FluentManager::try_new_with_discovered_modules()
163            .map_err(EmbeddedInitError::ModuleDiscovery)?;
164        manager
165            .select_language_strict(&lang)
166            .map_err(EmbeddedInitError::LanguageSelection)?;
167        Ok(Self::from_manager_with_active_selection(
168            manager,
169            Some(ActiveSelection {
170                language: lang,
171                policy: EmbeddedSelectionPolicy::Strict,
172            }),
173        ))
174    }
175
176    /// Selects the active language for this context.
177    ///
178    /// Concurrent selections through cloned handles are serialized. A failed
179    /// selection preserves the previously active language and selection policy.
180    pub fn select_language<L: Into<LanguageIdentifier>>(
181        &self,
182        lang: L,
183    ) -> Result<(), LocalizationError> {
184        self.select_language_with_policy(lang.into(), EmbeddedSelectionPolicy::BestEffort)
185    }
186
187    /// Selects the active language for this context and fails if any runtime
188    /// module rejects the requested locale.
189    ///
190    /// Shares the same serialized transition as [`Self::select_language`].
191    pub fn select_language_strict<L: Into<LanguageIdentifier>>(
192        &self,
193        lang: L,
194    ) -> Result<(), LocalizationError> {
195        self.select_language_with_policy(lang.into(), EmbeddedSelectionPolicy::Strict)
196    }
197
198    /// Renders a derived typed message through this context.
199    pub fn localize_message<T>(&self, message: &T) -> String
200    where
201        T: FluentMessage + ?Sized,
202    {
203        FluentLocalizerExt::localize_message(self, message)
204    }
205}
206
207impl FluentLocalizer for EmbeddedI18n {
208    fn localize<'a>(
209        &self,
210        key: StaticFluentMessageKey,
211        args: Option<&FluentArgs<'a>>,
212    ) -> Option<String> {
213        FluentManager::localize(self.manager.as_ref(), key, args.map(FluentArgs::as_raw))
214    }
215
216    fn with_lookup(&self, f: &mut dyn FnMut(&mut FluentLocalizerLookup<'_>)) {
217        FluentManager::with_lookup(self.manager.as_ref(), &mut |lookup| {
218            let mut typed_lookup = |key: StaticFluentMessageKey, args: Option<&FluentArgs<'_>>| {
219                lookup(key, args.map(FluentArgs::as_raw))
220            };
221            f(&mut typed_lookup);
222        });
223    }
224}
225
226#[cfg(test)]
227mod tests {
228    use super::*;
229    use es_fluent_manager_core::{
230        I18nModule, I18nModuleDescriptor, I18nModuleRegistration, Localizer, ModuleData,
231    };
232    use std::cell::RefCell;
233    use std::sync::{Mutex, Once, mpsc};
234    use std::time::Duration;
235    use unic_langid::langid;
236
237    thread_local! {
238        static AFTER_MANAGER_SELECTION: RefCell<Option<Box<dyn FnOnce()>>> = RefCell::new(None);
239    }
240
241    pub(super) fn after_manager_selection() {
242        let hook = AFTER_MANAGER_SELECTION.with_borrow_mut(Option::take);
243        if let Some(hook) = hook {
244            hook();
245        }
246    }
247
248    static TEST_SUPPORTED_LANGUAGES: &[LanguageIdentifier] = &[langid!("en-US"), langid!("fr")];
249    static TEST_MODULE_DATA: ModuleData = ModuleData {
250        name: "embedded-test-module",
251        owner: es_fluent_manager_core::__macro::static_domain("embedded-test-module"),
252        supported_languages: TEST_SUPPORTED_LANGUAGES,
253        domains: &[es_fluent_manager_core::ModuleDomain {
254            domain: es_fluent_manager_core::__macro::static_domain("embedded-test-module"),
255            namespaces: &[],
256        }],
257    };
258
259    struct TestModule;
260
261    struct TestLocalizer {
262        selected: Mutex<LanguageIdentifier>,
263    }
264
265    impl I18nModuleDescriptor for TestModule {
266        fn data(&self) -> &'static ModuleData {
267            &TEST_MODULE_DATA
268        }
269    }
270
271    impl I18nModule for TestModule {
272        fn create_localizer(&self) -> Box<dyn Localizer> {
273            Box::new(TestLocalizer {
274                selected: Mutex::new(langid!("en-US")),
275            })
276        }
277    }
278
279    impl Localizer for TestLocalizer {
280        fn select_language(&self, lang: &LanguageIdentifier) -> Result<(), LocalizationError> {
281            if TEST_SUPPORTED_LANGUAGES
282                .iter()
283                .any(|candidate| candidate == lang)
284            {
285                let mut selected = self
286                    .selected
287                    .lock()
288                    .expect("test localizer language lock should not be poisoned");
289                *selected = lang.clone();
290                Ok(())
291            } else {
292                Err(LocalizationError::LanguageNotSupported(lang.clone()))
293            }
294        }
295
296        fn localize<'a>(
297            &self,
298            key: StaticFluentMessageKey,
299            _args: Option<&es_fluent_manager_core::FluentArgumentMap<'a>>,
300        ) -> Option<String> {
301            if key.owner() != "embedded-test-module" || key.domain() != "embedded-test-module" {
302                return None;
303            }
304            let selected = self
305                .selected
306                .lock()
307                .expect("test localizer language lock should not be poisoned")
308                .to_string();
309            let value = match (selected.as_str(), key.id().as_str()) {
310                ("en-US", "hello") => "Hello",
311                ("fr", "hello") => "Bonjour",
312                _ => return None,
313            };
314
315            Some(value.to_string())
316        }
317    }
318
319    struct TestMessage;
320
321    impl FluentMessage for TestMessage {
322        fn to_fluent_string_with(
323            &self,
324            localize: &mut es_fluent::FluentMessageLookup<'_>,
325        ) -> String {
326            localize(
327                es_fluent::registry::__macro::static_message_key(
328                    "embedded-test-module",
329                    es_fluent::registry::__macro::static_domain("embedded-test-module"),
330                    es_fluent::registry::__macro::static_entry_id("hello"),
331                ),
332                None,
333            )
334        }
335    }
336
337    static TEST_MODULE: TestModule = TestModule;
338    static INVENTORY_ONCE: Once = Once::new();
339
340    crate::__inventory::submit!(&TEST_MODULE as &dyn I18nModuleRegistration);
341
342    fn force_inventory_link() {
343        INVENTORY_ONCE.call_once(|| {
344            let _ = &TEST_MODULE;
345        });
346    }
347
348    fn static_key(id: &'static str) -> StaticFluentMessageKey {
349        es_fluent::registry::__macro::static_message_key(
350            "embedded-test-module",
351            es_fluent::registry::__macro::static_domain("embedded-test-module"),
352            es_fluent::registry::__macro::static_entry_id(id),
353        )
354    }
355
356    fn select_with_policy(
357        i18n: &EmbeddedI18n,
358        language: LanguageIdentifier,
359        policy: EmbeddedSelectionPolicy,
360    ) -> Result<(), LocalizationError> {
361        match policy {
362            EmbeddedSelectionPolicy::BestEffort => i18n.select_language(language),
363            EmbeddedSelectionPolicy::Strict => i18n.select_language_strict(language),
364        }
365    }
366
367    #[test]
368    fn concurrent_selections_keep_cached_and_rendered_languages_consistent() {
369        force_inventory_link();
370        for first_policy in [
371            EmbeddedSelectionPolicy::BestEffort,
372            EmbeddedSelectionPolicy::Strict,
373        ] {
374            let second_policy = match first_policy {
375                EmbeddedSelectionPolicy::BestEffort => EmbeddedSelectionPolicy::Strict,
376                EmbeddedSelectionPolicy::Strict => EmbeddedSelectionPolicy::BestEffort,
377            };
378            let i18n = EmbeddedI18n::try_new().unwrap();
379            select_with_policy(&i18n, langid!("en-US"), first_policy).unwrap();
380            let (committed_tx, committed_rx) = mpsc::channel();
381            let (resume_tx, resume_rx) = mpsc::channel();
382            let (finished_tx, finished_rx) = mpsc::channel();
383            let timeout = Duration::from_secs(10);
384
385            std::thread::scope(|scope| {
386                let first_i18n = i18n.clone();
387                let first = scope.spawn(move || {
388                    AFTER_MANAGER_SELECTION.set(Some(Box::new(move || {
389                        committed_tx.send(()).unwrap();
390                        resume_rx.recv_timeout(timeout).unwrap();
391                    })));
392                    select_with_policy(&first_i18n, langid!("fr"), first_policy)
393                });
394                committed_rx.recv_timeout(timeout).unwrap();
395
396                // If the cache is unlocked after the manager commit, expose the old
397                // race by completing the second selection before the first caches it.
398                // Otherwise release the serialized first selection so the second runs.
399                let selection_is_locked = i18n.active_selection.try_write().is_err();
400                let second_i18n = i18n.clone();
401                let second = scope.spawn(move || {
402                    let result = select_with_policy(&second_i18n, langid!("en-US"), second_policy);
403                    finished_tx.send(()).unwrap();
404                    result
405                });
406                if selection_is_locked {
407                    resume_tx.send(()).unwrap();
408                    finished_rx.recv_timeout(timeout).unwrap();
409                } else {
410                    finished_rx.recv_timeout(timeout).unwrap();
411                    resume_tx.send(()).unwrap();
412                }
413                first.join().unwrap().unwrap();
414                second.join().unwrap().unwrap();
415            });
416
417            assert_eq!(i18n.localize_message(&TestMessage), "Hello");
418            select_with_policy(&i18n, langid!("fr"), first_policy).unwrap();
419            assert_eq!(i18n.localize_message(&TestMessage), "Bonjour");
420        }
421    }
422
423    #[test]
424    fn rejected_selections_preserve_the_active_language_and_policy() {
425        force_inventory_link();
426        let i18n = EmbeddedI18n::try_new_with_language(langid!("en-US")).unwrap();
427        let initial = i18n.active_selection.read().unwrap().clone();
428        for policy in [
429            EmbeddedSelectionPolicy::BestEffort,
430            EmbeddedSelectionPolicy::Strict,
431        ] {
432            std::assert_matches!(
433                select_with_policy(&i18n, langid!("de"), policy),
434                Err(LocalizationError::LanguageNotSupported(language)) if language == langid!("de")
435            );
436            assert_eq!(i18n.localize_message(&TestMessage), "Hello");
437            assert_eq!(*i18n.active_selection.read().unwrap(), initial);
438        }
439    }
440
441    #[test]
442    fn embedded_i18n_instances_select_languages_independently() {
443        force_inventory_link();
444        let en = EmbeddedI18n::try_new_with_language(langid!("en-US"))
445            .expect("en embedded i18n should initialize");
446        let fr = EmbeddedI18n::try_new_with_language(langid!("fr"))
447            .expect("fr embedded i18n should initialize");
448
449        assert_eq!(
450            es_fluent::FluentLocalizer::localize(&en, static_key("hello"), None),
451            Some("Hello".to_string())
452        );
453        assert_eq!(
454            es_fluent::FluentLocalizer::localize(&fr, static_key("hello"), None),
455            Some("Bonjour".to_string())
456        );
457
458        en.select_language(langid!("fr"))
459            .expect("en manager should switch to fr");
460
461        assert_eq!(
462            es_fluent::FluentLocalizer::localize(&en, static_key("hello"), None),
463            Some("Bonjour".to_string())
464        );
465        assert_eq!(
466            es_fluent::FluentLocalizer::localize(&fr, static_key("hello"), None),
467            Some("Bonjour".to_string())
468        );
469
470        fr.select_language(langid!("en-US"))
471            .expect("fr manager should switch to en-US");
472
473        assert_eq!(
474            es_fluent::FluentLocalizer::localize(&en, static_key("hello"), None),
475            Some("Bonjour".to_string())
476        );
477        assert_eq!(
478            es_fluent::FluentLocalizer::localize(&fr, static_key("hello"), None),
479            Some("Hello".to_string())
480        );
481    }
482
483    #[test]
484    fn embedded_i18n_facade_methods_delegate_to_manager_and_typed_lookup() {
485        force_inventory_link();
486        let i18n = EmbeddedI18n::try_new_with_language(langid!("en-US"))
487            .expect("embedded i18n should initialize");
488
489        assert_eq!(
490            es_fluent::FluentLocalizer::localize(&i18n, static_key("hello"), None),
491            Some("Hello".to_string())
492        );
493        assert_eq!(i18n.localize_message(&TestMessage), "Hello");
494        assert!(
495            i18n.select_language_strict(langid!("de")).is_err(),
496            "strict selection should reject unsupported locales"
497        );
498    }
499
500    #[test]
501    fn embedded_i18n_strict_initialization_tracks_active_language() {
502        force_inventory_link();
503        let i18n = EmbeddedI18n::try_new_with_language_strict(langid!("en-US"))
504            .expect("strict embedded i18n should initialize");
505
506        i18n.select_language_strict(langid!("en-US"))
507            .expect("strictly selecting the active language should be a no-op");
508        assert_eq!(
509            es_fluent::FluentLocalizer::localize(&i18n, static_key("hello"), None),
510            Some("Hello".to_string())
511        );
512        assert_eq!(
513            es_fluent::FluentLocalizer::localize(&i18n, static_key("unknown"), None),
514            None
515        );
516
517        i18n.select_language(langid!("en-US"))
518            .expect("best-effort selection should store its own active policy");
519        i18n.select_language(langid!("en-US"))
520            .expect("best-effort selecting the active language should be a no-op");
521    }
522
523    #[test]
524    fn embedded_i18n_try_new_builds_context_before_language_selection() {
525        force_inventory_link();
526        let i18n = EmbeddedI18n::try_new().expect("embedded i18n should initialize");
527        let cloned = i18n.clone();
528
529        assert_eq!(
530            es_fluent::FluentLocalizer::localize(&i18n, static_key("hello"), None),
531            None
532        );
533        assert_eq!(i18n.try_localize_message(&TestMessage), None);
534        cloned
535            .select_language(langid!("fr"))
536            .expect("language selection should work after initialization");
537        assert_eq!(i18n.localize_message(&TestMessage), "Bonjour");
538        assert_eq!(
539            es_fluent::FluentLocalizer::localize(&i18n, static_key("hello"), None),
540            Some("Bonjour".to_string())
541        );
542    }
543
544    #[test]
545    fn embedded_init_error_display_and_source_match_error_kind() {
546        use es_fluent_manager_core::{ModuleDiscoveryError, ModuleRegistrationKind};
547        use std::error::Error as _;
548
549        let discovery = EmbeddedInitError::ModuleDiscovery(vec![
550            ModuleDiscoveryError::DuplicateModuleRegistration {
551                name: "app".to_string(),
552                owner: "app".to_string(),
553                kind: ModuleRegistrationKind::MetadataOnly,
554                count: 2,
555            },
556        ]);
557        assert!(
558            discovery
559                .to_string()
560                .contains("failed strict i18n module discovery")
561        );
562        assert!(discovery.source().is_none());
563
564        let selection = EmbeddedInitError::LanguageSelection(
565            LocalizationError::LanguageNotSupported(langid!("de")),
566        );
567        assert!(
568            selection
569                .to_string()
570                .contains("failed to select the requested language")
571        );
572        assert!(selection.source().is_some());
573    }
574}