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#[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 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 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 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 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 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 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 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 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}