Skip to main content

es_fluent_manager_core/
embedded_localization.rs

1//! This module provides types for managing embedded translations.
2
3use crate::fallback::fallback_locales;
4use crate::localization::{I18nModule, LocalizationError, Localizer};
5use fluent_bundle::{FluentArgs, FluentBundle, FluentResource, FluentValue};
6use fluent_fallback::env::LocalesProvider as _;
7use rust_embed::RustEmbed;
8use std::collections::HashMap;
9use std::sync::{Arc, RwLock};
10use unic_langid::LanguageIdentifier;
11
12pub trait EmbeddedAssets: RustEmbed + Send + Sync + 'static {
13    fn domain() -> &'static str;
14}
15
16#[derive(Debug)]
17pub struct EmbeddedModuleData {
18    /// The name of the module.
19    pub name: &'static str,
20    /// The domain of the module.
21    pub domain: &'static str,
22    /// The supported languages of the module.
23    pub supported_languages: &'static [LanguageIdentifier],
24    /// The namespaces used by this module's types (e.g., "ui", "errors").
25    /// If empty, only the main domain file (e.g., `bevy-example.ftl`) is loaded.
26    pub namespaces: &'static [&'static str],
27}
28
29#[derive(Debug)]
30pub struct EmbeddedLocalizer<T: EmbeddedAssets> {
31    data: &'static EmbeddedModuleData,
32    current_resources: RwLock<Vec<Arc<FluentResource>>>,
33    current_lang: RwLock<Option<LanguageIdentifier>>,
34    _phantom: std::marker::PhantomData<T>,
35}
36
37impl<T: EmbeddedAssets> EmbeddedLocalizer<T> {
38    pub fn new(data: &'static EmbeddedModuleData) -> Self {
39        Self {
40            data,
41            current_resources: RwLock::new(Vec::new()),
42            current_lang: RwLock::new(None),
43            _phantom: std::marker::PhantomData,
44        }
45    }
46
47    fn load_resource_for_language(
48        &self,
49        lang: &LanguageIdentifier,
50    ) -> Result<Vec<Arc<FluentResource>>, LocalizationError> {
51        let mut resources = Vec::new();
52
53        // Load main resource if it exists (for backwards compatibility)
54        let main_file_name = format!("{}.ftl", self.data.domain);
55        let main_file_path = format!("{}/{}", lang, main_file_name);
56
57        if let Some(file_data) = T::get(&main_file_path) {
58            let content = String::from_utf8(file_data.data.to_vec()).map_err(|e| {
59                LocalizationError::BackendError(anyhow::anyhow!(
60                    "Invalid UTF-8 in embedded file '{}': {}",
61                    main_file_path,
62                    e
63                ))
64            })?;
65
66            let resource = FluentResource::try_new(content).map_err(|(_, errs)| {
67                LocalizationError::BackendError(anyhow::anyhow!(
68                    "Failed to parse fluent resource from '{}': {:?}",
69                    main_file_path,
70                    errs
71                ))
72            })?;
73            resources.push(Arc::new(resource));
74        }
75
76        // Load namespaced resources
77        for ns in self.data.namespaces {
78            let ns_file_name = format!("{}.ftl", ns);
79            let ns_file_path = format!("{}/{}/{}", lang, self.data.domain, ns_file_name);
80
81            if let Some(file_data) = T::get(&ns_file_path) {
82                let content = String::from_utf8(file_data.data.to_vec()).map_err(|e| {
83                    LocalizationError::BackendError(anyhow::anyhow!(
84                        "Invalid UTF-8 in embedded file '{}': {}",
85                        ns_file_path,
86                        e
87                    ))
88                })?;
89
90                let resource = FluentResource::try_new(content).map_err(|(_, errs)| {
91                    LocalizationError::BackendError(anyhow::anyhow!(
92                        "Failed to parse fluent resource from '{}': {:?}",
93                        ns_file_path,
94                        errs
95                    ))
96                })?;
97                resources.push(Arc::new(resource));
98            }
99        }
100
101        if resources.is_empty() {
102            Err(LocalizationError::LanguageNotSupported(lang.clone()))
103        } else {
104            Ok(resources)
105        }
106    }
107}
108
109impl<T: EmbeddedAssets> Localizer for EmbeddedLocalizer<T> {
110    fn select_language(&self, lang: &LanguageIdentifier) -> Result<(), LocalizationError> {
111        let mut current_lang_guard = self.current_lang.write().unwrap();
112        for candidate in fallback_locales(lang).locales() {
113            if !self
114                .data
115                .supported_languages
116                .iter()
117                .any(|supported| supported == &candidate)
118            {
119                continue;
120            }
121
122            if current_lang_guard.as_ref() == Some(&candidate) {
123                return Ok(());
124            }
125
126            if let Ok(resources) = self.load_resource_for_language(&candidate) {
127                *self.current_resources.write().unwrap() = resources;
128                *current_lang_guard = Some(candidate);
129                return Ok(());
130            }
131        }
132
133        Err(LocalizationError::LanguageNotSupported(lang.clone()))
134    }
135
136    fn localize<'a>(
137        &self,
138        id: &str,
139        args: Option<&HashMap<&str, FluentValue<'a>>>,
140    ) -> Option<String> {
141        let resources = self.current_resources.read().unwrap();
142        if resources.is_empty() {
143            return None;
144        }
145
146        let lang_guard = self.current_lang.read().unwrap();
147        let lang = lang_guard
148            .as_ref()
149            .expect("Language not selected before localization");
150
151        let mut bundle = FluentBundle::new(vec![lang.clone()]);
152        for resource in resources.iter() {
153            if let Err(e) = bundle.add_resource(resource.clone()) {
154                tracing::error!("Failed to add resource to bundle: {:?}", e);
155            }
156        }
157
158        let message = bundle.get_message(id)?;
159        let pattern = message.value()?;
160
161        let fluent_args = args.map(|args| {
162            let mut fa = FluentArgs::new();
163            for (key, value) in args {
164                fa.set(*key, value.clone());
165            }
166            fa
167        });
168
169        let mut errors = Vec::new();
170        let value = bundle.format_pattern(pattern, fluent_args.as_ref(), &mut errors);
171
172        if !errors.is_empty() {
173            tracing::error!("Fluent formatting errors for id '{}': {:?}", id, errors);
174            return None;
175        }
176
177        Some(value.into_owned())
178    }
179}
180
181pub struct EmbeddedI18nModule<T: EmbeddedAssets> {
182    data: &'static EmbeddedModuleData,
183    _phantom: std::marker::PhantomData<T>,
184}
185
186impl<T: EmbeddedAssets> EmbeddedI18nModule<T> {
187    pub const fn new(data: &'static EmbeddedModuleData) -> Self {
188        Self {
189            data,
190            _phantom: std::marker::PhantomData,
191        }
192    }
193
194    pub fn discover_languages() -> Vec<LanguageIdentifier> {
195        let domain = T::domain();
196        let file_name = format!("{}.ftl", domain);
197        let mut languages = Vec::new();
198        let mut seen = std::collections::HashSet::new();
199
200        for file_path in T::iter() {
201            let file_path_str = file_path.as_ref();
202
203            // Check for main domain file: {lang}/{domain}.ftl
204            if file_path_str.ends_with(&file_name) {
205                let suffix = format!("/{}", file_name);
206                if let Some(lang_part) = file_path_str.strip_suffix(&suffix)
207                    && let Ok(lang_id) = lang_part.parse::<LanguageIdentifier>()
208                    && seen.insert(lang_id.clone())
209                {
210                    languages.push(lang_id);
211                }
212            }
213
214            // Check for namespaced files: {lang}/{domain}/{namespace}.ftl
215            if let Some(parent) = std::path::Path::new(file_path_str).parent()
216                && let Some(parent_str) = parent.to_str()
217                && parent_str.ends_with(&format!("/{}", domain))
218                && let Some(lang_part) = parent_str.strip_suffix(&format!("/{}", domain))
219                && let Ok(lang_id) = lang_part.parse::<LanguageIdentifier>()
220                && seen.insert(lang_id.clone())
221            {
222                languages.push(lang_id);
223            }
224        }
225
226        languages.sort_by_key(|a| a.to_string());
227        languages
228    }
229}
230
231impl<T: EmbeddedAssets> I18nModule for EmbeddedI18nModule<T> {
232    fn name(&self) -> &'static str {
233        self.data.name
234    }
235
236    fn create_localizer(&self) -> Box<dyn Localizer> {
237        Box::new(EmbeddedLocalizer::<T>::new(self.data))
238    }
239}