1use crate::asset_localization::{
4 I18nModuleDescriptor, ModuleData, ModuleResourceSpec, StaticModuleDescriptor,
5 validate_module_registry,
6};
7use es_fluent_derive_core::EsFluentError;
8use fluent_bundle::{
9 FluentArgs, FluentError, FluentResource, FluentValue, bundle::FluentBundle,
10 memoizer::MemoizerKind,
11};
12use std::borrow::Borrow;
13use std::collections::HashMap;
14use std::sync::Arc;
15use unic_langid::LanguageIdentifier;
16
17pub type LocalizationError = EsFluentError;
18pub type SyncFluentBundle =
19 FluentBundle<Arc<FluentResource>, intl_memoizer::concurrent::IntlLangMemoizer>;
20
21pub fn add_resources_to_bundle<R, M>(
23 bundle: &mut FluentBundle<R, M>,
24 resources: impl IntoIterator<Item = R>,
25) -> Vec<Vec<FluentError>>
26where
27 R: Borrow<FluentResource>,
28 M: MemoizerKind,
29{
30 let mut add_errors = Vec::new();
31 for resource in resources {
32 if let Err(errors) = bundle.add_resource(resource) {
33 add_errors.push(errors);
34 }
35 }
36 add_errors
37}
38
39pub fn build_sync_bundle(
41 lang: &LanguageIdentifier,
42 resources: impl IntoIterator<Item = Arc<FluentResource>>,
43) -> (SyncFluentBundle, Vec<Vec<FluentError>>) {
44 let mut bundle = FluentBundle::new_concurrent(vec![lang.clone()]);
45 let add_errors = add_resources_to_bundle(&mut bundle, resources);
46 (bundle, add_errors)
47}
48
49pub fn build_fluent_args<'a>(
51 args: Option<&HashMap<&str, FluentValue<'a>>>,
52) -> Option<FluentArgs<'a>> {
53 args.map(|args| {
54 let mut fluent_args = FluentArgs::new();
55 for (key, value) in args {
56 fluent_args.set((*key).to_string(), value.clone());
57 }
58 fluent_args
59 })
60}
61
62pub fn localize_with_bundle<'a, R, M>(
67 bundle: &FluentBundle<R, M>,
68 id: &str,
69 args: Option<&HashMap<&str, FluentValue<'a>>>,
70) -> Option<(String, Vec<FluentError>)>
71where
72 R: Borrow<FluentResource>,
73 M: MemoizerKind,
74{
75 let message = bundle.get_message(id)?;
76 let pattern = message.value()?;
77 let fluent_args = build_fluent_args(args);
78 let mut errors = Vec::new();
79 let value = bundle.format_pattern(pattern, fluent_args.as_ref(), &mut errors);
80 Some((value.into_owned(), errors))
81}
82
83pub trait Localizer: Send + Sync {
84 fn select_language(
86 &self,
87 lang: &LanguageIdentifier,
88 ) -> es_fluent_derive_core::EsFluentResult<()>;
89 fn localize<'a>(
91 &self,
92 id: &str,
93 args: Option<&HashMap<&str, FluentValue<'a>>>,
94 ) -> Option<String>;
95}
96
97pub trait I18nModuleRegistration: I18nModuleDescriptor {
102 fn create_localizer(&self) -> Option<Box<dyn Localizer>> {
104 None
105 }
106
107 fn supports_runtime_localization(&self) -> bool {
112 self.create_localizer().is_some()
113 }
114
115 fn resource_plan_for_language(
120 &self,
121 _lang: &LanguageIdentifier,
122 ) -> Option<Vec<ModuleResourceSpec>> {
123 None
124 }
125}
126
127pub trait I18nModule: I18nModuleDescriptor {
128 fn create_localizer(&self) -> Box<dyn Localizer>;
130}
131
132impl<T: I18nModule> I18nModuleRegistration for T {
133 fn create_localizer(&self) -> Option<Box<dyn Localizer>> {
134 Some(I18nModule::create_localizer(self))
135 }
136
137 fn supports_runtime_localization(&self) -> bool {
138 true
139 }
140}
141
142impl I18nModuleRegistration for StaticModuleDescriptor {}
143
144inventory::collect!(&'static dyn I18nModuleRegistration);
145
146pub fn filter_module_registry(
156 modules: impl IntoIterator<Item = &'static dyn I18nModuleRegistration>,
157) -> Vec<&'static dyn I18nModuleRegistration> {
158 let modules = modules.into_iter().collect::<Vec<_>>();
159 let mut discovered_data_by_identity: HashMap<
160 (&'static str, &'static str),
161 &'static ModuleData,
162 > = HashMap::new();
163 for module in &modules {
164 let data = module.data();
165 discovered_data_by_identity
166 .entry((data.name, data.domain))
167 .or_insert(data);
168 }
169 let discovered_data = discovered_data_by_identity
170 .into_values()
171 .collect::<Vec<_>>();
172
173 if let Err(errors) = validate_module_registry(discovered_data.iter().copied()) {
174 for error in errors {
175 tracing::error!("Invalid i18n module registry entry: {}", error);
176 }
177 }
178
179 let mut filtered: Vec<&'static dyn I18nModuleRegistration> = Vec::with_capacity(modules.len());
180 let mut seen_module_names: HashMap<&'static str, usize> = HashMap::new();
181 let mut seen_domains: HashMap<&'static str, usize> = HashMap::new();
182
183 for module in modules {
184 let data = module.data();
185 if data.name.trim().is_empty() || data.domain.trim().is_empty() {
186 tracing::warn!(
187 "Skipping i18n module with invalid metadata: name='{}', domain='{}'",
188 data.name,
189 data.domain
190 );
191 continue;
192 }
193 if let Some(&existing_index) = seen_module_names.get(data.name) {
194 let existing = filtered[existing_index];
195 let existing_data = existing.data();
196 if existing_data.domain != data.domain {
197 tracing::warn!(
198 "Skipping duplicate i18n module name '{}' (domain '{}')",
199 data.name,
200 data.domain
201 );
202 continue;
203 }
204
205 if !existing.supports_runtime_localization() && module.supports_runtime_localization() {
206 tracing::warn!(
207 "Replacing metadata-only i18n module '{}' with runtime-localizer registration",
208 data.name
209 );
210 filtered[existing_index] = module;
211 } else {
212 tracing::warn!(
213 "Skipping duplicate i18n module name '{}' (domain '{}')",
214 data.name,
215 data.domain
216 );
217 }
218 continue;
219 }
220
221 if let Some(&existing_index) = seen_domains.get(data.domain) {
222 let existing = filtered[existing_index];
223 let existing_data = existing.data();
224 if existing_data.name == data.name {
225 if !existing.supports_runtime_localization()
226 && module.supports_runtime_localization()
227 {
228 tracing::warn!(
229 "Replacing metadata-only i18n module '{}' with runtime-localizer registration",
230 data.name
231 );
232 filtered[existing_index] = module;
233 } else {
234 tracing::warn!(
235 "Skipping duplicate i18n module name '{}' (domain '{}')",
236 data.name,
237 data.domain
238 );
239 }
240 continue;
241 }
242
243 tracing::warn!(
244 "Skipping duplicate i18n domain '{}' from module '{}'",
245 data.domain,
246 data.name
247 );
248 continue;
249 }
250
251 let index = filtered.len();
252 seen_module_names.insert(data.name, index);
253 seen_domains.insert(data.domain, index);
254 filtered.push(module);
255 }
256
257 filtered
258}
259
260#[derive(Default)]
262pub struct FluentManager {
263 localizers: Vec<(&'static ModuleData, Box<dyn Localizer>)>,
264}
265
266impl FluentManager {
267 pub fn new_with_discovered_modules() -> Self {
269 let discovered_modules = filter_module_registry(
270 inventory::iter::<&'static dyn I18nModuleRegistration>()
271 .copied()
272 .collect::<Vec<_>>(),
273 );
274
275 let mut manager = Self::default();
276
277 for module in discovered_modules {
278 let data = module.data();
279 tracing::info!("Discovered and loading i18n module: {}", data.name);
280 if let Some(localizer) = module.create_localizer() {
281 manager.localizers.push((data, localizer));
282 } else {
283 tracing::debug!(
284 "Skipping metadata-only i18n module '{}' for FluentManager runtime localization",
285 data.name
286 );
287 }
288 }
289 manager
290 }
291
292 pub fn select_language(&self, lang: &LanguageIdentifier) {
294 let mut any_selected = false;
295
296 for (data, localizer) in &self.localizers {
297 match localizer.select_language(lang) {
298 Ok(()) => {
299 any_selected = true;
300 },
301 Err(e) => {
302 tracing::debug!(
303 "Module '{}' failed to set language '{}': {}",
304 data.name,
305 lang,
306 e
307 );
308 },
309 }
310 }
311
312 if !any_selected {
313 tracing::warn!("No i18n modules support language '{}'", lang);
314 }
315 }
316
317 pub fn localize<'a>(
319 &self,
320 id: &str,
321 args: Option<&HashMap<&str, FluentValue<'a>>>,
322 ) -> Option<String> {
323 for (_, localizer) in &self.localizers {
324 if let Some(message) = localizer.localize(id, args) {
325 return Some(message);
326 }
327 }
328 None
329 }
330}
331
332#[cfg(test)]
333mod tests {
334 use super::*;
335 use fluent_bundle::FluentResource;
336 use std::sync::atomic::{AtomicUsize, Ordering};
337 use unic_langid::langid;
338
339 static SELECT_OK_CALLS: AtomicUsize = AtomicUsize::new(0);
340 static SELECT_ERR_CALLS: AtomicUsize = AtomicUsize::new(0);
341 static MODULE_OK_DATA: ModuleData = ModuleData {
342 name: "module-ok",
343 domain: "module-ok",
344 supported_languages: &[],
345 namespaces: &[],
346 };
347 static MODULE_ERR_DATA: ModuleData = ModuleData {
348 name: "module-err",
349 domain: "module-err",
350 supported_languages: &[],
351 namespaces: &[],
352 };
353 static FILTER_MODULE_DATA: ModuleData = ModuleData {
354 name: "filter-module",
355 domain: "filter-domain",
356 supported_languages: &[],
357 namespaces: &[],
358 };
359 static FILTER_DUP_NAME_DATA: ModuleData = ModuleData {
360 name: "filter-module",
361 domain: "filter-domain-b",
362 supported_languages: &[],
363 namespaces: &[],
364 };
365 static FILTER_DUP_DOMAIN_DATA: ModuleData = ModuleData {
366 name: "filter-module-b",
367 domain: "filter-domain",
368 supported_languages: &[],
369 namespaces: &[],
370 };
371 static FILTER_EXACT_DUP_DATA: ModuleData = ModuleData {
372 name: "filter-exact-module",
373 domain: "filter-exact-domain",
374 supported_languages: &[],
375 namespaces: &[],
376 };
377 static FILTER_DESCRIPTOR: StaticModuleDescriptor =
378 StaticModuleDescriptor::new(&FILTER_MODULE_DATA);
379 static FILTER_DUP_NAME_DESCRIPTOR: StaticModuleDescriptor =
380 StaticModuleDescriptor::new(&FILTER_DUP_NAME_DATA);
381 static FILTER_DUP_DOMAIN_DESCRIPTOR: StaticModuleDescriptor =
382 StaticModuleDescriptor::new(&FILTER_DUP_DOMAIN_DATA);
383 static FILTER_EXACT_DUP_DESCRIPTOR: StaticModuleDescriptor =
384 StaticModuleDescriptor::new(&FILTER_EXACT_DUP_DATA);
385
386 struct ModuleOk;
387 struct ModuleErr;
388 struct FilterRuntimeModule;
389
390 struct LocalizerOk;
391 struct LocalizerErr;
392 struct FilterRuntimeLocalizer;
393
394 impl Localizer for LocalizerOk {
395 fn select_language(&self, _lang: &LanguageIdentifier) -> Result<(), LocalizationError> {
396 SELECT_OK_CALLS.fetch_add(1, Ordering::Relaxed);
397 Ok(())
398 }
399
400 fn localize<'a>(
401 &self,
402 id: &str,
403 _args: Option<&HashMap<&str, FluentValue<'a>>>,
404 ) -> Option<String> {
405 match id {
406 "from-ok" => Some("ok-value".to_string()),
407 _ => None,
408 }
409 }
410 }
411
412 impl Localizer for LocalizerErr {
413 fn select_language(&self, lang: &LanguageIdentifier) -> Result<(), LocalizationError> {
414 SELECT_ERR_CALLS.fetch_add(1, Ordering::Relaxed);
415 Err(LocalizationError::LanguageNotSupported(lang.clone()))
416 }
417
418 fn localize<'a>(
419 &self,
420 id: &str,
421 _args: Option<&HashMap<&str, FluentValue<'a>>>,
422 ) -> Option<String> {
423 if id == "from-err" {
424 Some("err-value".to_string())
425 } else {
426 None
427 }
428 }
429 }
430
431 impl Localizer for FilterRuntimeLocalizer {
432 fn select_language(&self, _lang: &LanguageIdentifier) -> Result<(), LocalizationError> {
433 Ok(())
434 }
435
436 fn localize<'a>(
437 &self,
438 _id: &str,
439 _args: Option<&HashMap<&str, FluentValue<'a>>>,
440 ) -> Option<String> {
441 None
442 }
443 }
444
445 impl I18nModuleDescriptor for ModuleOk {
446 fn data(&self) -> &'static ModuleData {
447 &MODULE_OK_DATA
448 }
449 }
450
451 impl I18nModule for ModuleOk {
452 fn create_localizer(&self) -> Box<dyn Localizer> {
453 Box::new(LocalizerOk)
454 }
455 }
456
457 impl I18nModuleDescriptor for ModuleErr {
458 fn data(&self) -> &'static ModuleData {
459 &MODULE_ERR_DATA
460 }
461 }
462
463 impl I18nModule for ModuleErr {
464 fn create_localizer(&self) -> Box<dyn Localizer> {
465 Box::new(LocalizerErr)
466 }
467 }
468
469 impl I18nModuleDescriptor for FilterRuntimeModule {
470 fn data(&self) -> &'static ModuleData {
471 &FILTER_EXACT_DUP_DATA
472 }
473 }
474
475 impl I18nModule for FilterRuntimeModule {
476 fn create_localizer(&self) -> Box<dyn Localizer> {
477 Box::new(FilterRuntimeLocalizer)
478 }
479 }
480
481 static MODULE_OK: ModuleOk = ModuleOk;
482 static MODULE_ERR: ModuleErr = ModuleErr;
483 static FILTER_RUNTIME_MODULE: FilterRuntimeModule = FilterRuntimeModule;
484
485 inventory::submit! {
486 &MODULE_OK as &dyn I18nModuleRegistration
487 }
488
489 inventory::submit! {
490 &MODULE_ERR as &dyn I18nModuleRegistration
491 }
492
493 #[test]
494 fn manager_select_language_calls_all_localizers() {
495 let ok_before = SELECT_OK_CALLS.load(Ordering::Relaxed);
496 let err_before = SELECT_ERR_CALLS.load(Ordering::Relaxed);
497
498 let manager = FluentManager::new_with_discovered_modules();
499 manager.select_language(&langid!("en-US"));
500
501 assert!(SELECT_OK_CALLS.load(Ordering::Relaxed) > ok_before);
502 assert!(SELECT_ERR_CALLS.load(Ordering::Relaxed) > err_before);
503 }
504
505 #[test]
506 fn manager_localize_returns_first_matching_message() {
507 let manager = FluentManager::new_with_discovered_modules();
508 assert_eq!(
509 manager.localize("from-ok", None),
510 Some("ok-value".to_string())
511 );
512 assert_eq!(
513 manager.localize("from-err", None),
514 Some("err-value".to_string())
515 );
516 assert_eq!(manager.localize("missing", None), None);
517 }
518
519 #[test]
520 fn manager_select_language_with_only_failing_localizers_covers_warn_path() {
521 let err_before = SELECT_ERR_CALLS.load(Ordering::Relaxed);
522
523 let manager = FluentManager {
524 localizers: vec![(&MODULE_ERR_DATA, Box::new(LocalizerErr))],
525 };
526 manager.select_language(&langid!("en-US"));
527
528 assert!(SELECT_ERR_CALLS.load(Ordering::Relaxed) > err_before);
529 }
530
531 #[test]
532 fn build_sync_bundle_reports_resource_add_errors() {
533 let lang = langid!("en-US");
534 let first =
535 Arc::new(FluentResource::try_new("hello = first".to_string()).expect("valid ftl"));
536 let duplicate =
537 Arc::new(FluentResource::try_new("hello = second".to_string()).expect("valid ftl"));
538
539 let (bundle, add_errors) = build_sync_bundle(&lang, vec![first, duplicate]);
540 assert!(!add_errors.is_empty());
541
542 let (localized, _format_errors) =
543 localize_with_bundle(&bundle, "hello", None).expect("message should exist");
544 assert_eq!(localized, "first");
545 }
546
547 #[test]
548 fn filter_module_registry_skips_duplicate_name_and_domain() {
549 let filtered = filter_module_registry([
550 &FILTER_DESCRIPTOR as &dyn I18nModuleRegistration,
551 &FILTER_DUP_NAME_DESCRIPTOR as &dyn I18nModuleRegistration,
552 &FILTER_DUP_DOMAIN_DESCRIPTOR as &dyn I18nModuleRegistration,
553 ]);
554
555 assert_eq!(filtered.len(), 1);
556 assert_eq!(filtered[0].data().name, "filter-module");
557 }
558
559 #[test]
560 fn filter_module_registry_prefers_runtime_localizer_for_exact_duplicate_identity() {
561 let filtered = filter_module_registry([
562 &FILTER_EXACT_DUP_DESCRIPTOR as &dyn I18nModuleRegistration,
563 &FILTER_RUNTIME_MODULE as &dyn I18nModuleRegistration,
564 ]);
565
566 assert_eq!(filtered.len(), 1);
567 assert!(filtered[0].create_localizer().is_some());
568 }
569
570 #[test]
571 fn filter_module_registry_keeps_runtime_localizer_when_metadata_duplicate_follows() {
572 let filtered = filter_module_registry([
573 &FILTER_RUNTIME_MODULE as &dyn I18nModuleRegistration,
574 &FILTER_EXACT_DUP_DESCRIPTOR as &dyn I18nModuleRegistration,
575 ]);
576
577 assert_eq!(filtered.len(), 1);
578 assert!(filtered[0].create_localizer().is_some());
579 }
580}