Skip to main content

es_fluent_manager_core/
localization.rs

1//! This module provides the core types for managing translations.
2
3use 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
21/// Adds resources to a bundle and returns all resource-add errors.
22pub 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
39/// Builds a concurrent `FluentBundle` from a locale and resources.
40pub 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
49/// Converts hash-map arguments into `FluentArgs`.
50pub 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
62/// Localizes a message from an already-built Fluent bundle.
63///
64/// Returns `None` when the message or value is missing.
65/// Returns the formatted value and collected formatting errors otherwise.
66pub 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    /// Selects a language for the localizer.
85    fn select_language(
86        &self,
87        lang: &LanguageIdentifier,
88    ) -> es_fluent_derive_core::EsFluentResult<()>;
89    /// Localizes a message by its ID.
90    fn localize<'a>(
91        &self,
92        id: &str,
93        args: Option<&HashMap<&str, FluentValue<'a>>>,
94    ) -> Option<String>;
95}
96
97/// Unified inventory contract for all module registrations.
98///
99/// Backends that only provide metadata (for example Bevy asset-driven loading)
100/// can return `None` from `create_localizer`.
101pub trait I18nModuleRegistration: I18nModuleDescriptor {
102    /// Creates a localizer when the registration supports runtime localization.
103    fn create_localizer(&self) -> Option<Box<dyn Localizer>> {
104        None
105    }
106
107    /// Returns whether this registration can provide a runtime localizer.
108    ///
109    /// Implementations can override this to avoid constructing a localizer just
110    /// for capability checks during duplicate-resolution.
111    fn supports_runtime_localization(&self) -> bool {
112        self.create_localizer().is_some()
113    }
114
115    /// Returns an optional manifest-derived resource plan for a specific language.
116    ///
117    /// When this returns `Some`, managers should use this plan directly instead of
118    /// inferring optional resource existence at runtime.
119    fn resource_plan_for_language(
120        &self,
121        _lang: &LanguageIdentifier,
122    ) -> Option<Vec<ModuleResourceSpec>> {
123        None
124    }
125}
126
127pub trait I18nModule: I18nModuleDescriptor {
128    /// Creates a localizer for the module.
129    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
146/// Normalizes discovered module registrations into a consistent, deduplicated list.
147///
148/// This applies shared validation and keeps only entries that satisfy:
149/// - non-empty module name and domain
150/// - unique module identity (`name` + `domain`)
151/// - no conflicting duplicate names/domains
152///
153/// For exact duplicates (`name` + `domain`), runtime-localizer registrations are
154/// preferred over metadata-only registrations.
155pub 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/// A manager for Fluent translations.
261#[derive(Default)]
262pub struct FluentManager {
263    localizers: Vec<(&'static ModuleData, Box<dyn Localizer>)>,
264}
265
266impl FluentManager {
267    /// Creates a new `FluentManager` with discovered i18n modules.
268    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    /// Selects a language for all localizers.
293    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    /// Localizes a message by its ID.
318    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}