Skip to main content

es_fluent_lang/
lib.rs

1#![doc = include_str!("../README.md")]
2
3pub use unic_langid::{LanguageIdentifier, langid};
4
5#[cfg(feature = "macros")]
6pub use es_fluent_lang_macro::es_fluent_language;
7
8use es_fluent_manager_core::{I18nModule, LocalizationError, Localizer};
9use fluent_bundle::{FluentArgs, FluentBundle, FluentResource, FluentValue};
10use std::collections::HashMap;
11use std::sync::{Arc, OnceLock, RwLock};
12
13#[cfg(not(feature = "minimal"))]
14use rust_embed::RustEmbed;
15#[cfg(not(feature = "minimal"))]
16use std::collections::HashSet;
17
18#[cfg(feature = "minimal")]
19const ES_FLUENT_LANG_FTL: &str = include_str!("../es-fluent-lang.ftl");
20
21#[cfg(feature = "minimal")]
22fn embedded_resource() -> Arc<FluentResource> {
23    static RESOURCE: OnceLock<Arc<FluentResource>> = OnceLock::new();
24    RESOURCE
25        .get_or_init(|| {
26            Arc::new(
27                FluentResource::try_new(ES_FLUENT_LANG_FTL.to_owned()).expect(
28                    "Invalid Fluent resource embedded in es-fluent-lang/es-fluent-lang.ftl",
29                ),
30            )
31        })
32        .clone()
33}
34
35#[cfg(not(feature = "minimal"))]
36const I18N_RESOURCE_NAME: &str = "es-fluent-lang.ftl";
37
38#[cfg(not(feature = "minimal"))]
39#[derive(RustEmbed)]
40#[folder = "i18n"]
41struct EsFluentLangAssets;
42
43#[cfg(not(feature = "minimal"))]
44fn available_languages() -> &'static HashSet<LanguageIdentifier> {
45    static AVAILABLE: OnceLock<HashSet<LanguageIdentifier>> = OnceLock::new();
46    AVAILABLE.get_or_init(|| {
47        let mut set = HashSet::new();
48        for file in EsFluentLangAssets::iter() {
49            let path = file.as_ref();
50            if let Some((lang, file_name)) = path.rsplit_once('/')
51                && file_name == I18N_RESOURCE_NAME
52                && let Ok(lang_id) = lang.parse::<LanguageIdentifier>()
53            {
54                set.insert(lang_id);
55            }
56        }
57        set
58    })
59}
60
61#[cfg(not(feature = "minimal"))]
62fn candidate_languages(lang: &LanguageIdentifier) -> Vec<LanguageIdentifier> {
63    let mut candidates = Vec::new();
64    let mut push = |candidate: LanguageIdentifier| {
65        if !candidates.iter().any(|existing| existing == &candidate) {
66            candidates.push(candidate);
67        }
68    };
69
70    push(lang.clone());
71
72    let mut without_variants = lang.clone();
73    without_variants.clear_variants();
74    push(without_variants.clone());
75
76    if without_variants.region.is_some() {
77        let mut no_region = without_variants.clone();
78        no_region.region = None;
79        push(no_region);
80    }
81
82    if without_variants.script.is_some() {
83        let mut no_script = without_variants.clone();
84        no_script.script = None;
85        push(no_script);
86    }
87
88    if let Ok(primary) = without_variants
89        .language
90        .as_str()
91        .parse::<LanguageIdentifier>()
92    {
93        push(primary);
94    }
95
96    candidates
97}
98
99#[cfg(not(feature = "minimal"))]
100fn resolve_language(lang: &LanguageIdentifier) -> Option<LanguageIdentifier> {
101    let available = available_languages();
102    candidate_languages(lang)
103        .into_iter()
104        .find(|candidate| available.contains(candidate))
105}
106
107struct EsFluentLanguageModule;
108
109impl I18nModule for EsFluentLanguageModule {
110    fn name(&self) -> &'static str {
111        "es-fluent-lang"
112    }
113
114    fn create_localizer(&self) -> Box<dyn Localizer> {
115        #[cfg(feature = "minimal")]
116        {
117            Box::new(EsFluentLanguageLocalizer::new(
118                embedded_resource(),
119                langid!("en-US"),
120            ))
121        }
122
123        #[cfg(not(feature = "minimal"))]
124        {
125            Box::new(EsFluentLanguageLocalizer::new(langid!("en-US")))
126        }
127    }
128}
129
130#[cfg(feature = "minimal")]
131struct EsFluentLanguageLocalizer {
132    resource: Arc<FluentResource>,
133    current_lang: RwLock<LanguageIdentifier>,
134}
135
136#[cfg(feature = "minimal")]
137impl EsFluentLanguageLocalizer {
138    fn new(resource: Arc<FluentResource>, default_lang: LanguageIdentifier) -> Self {
139        Self {
140            resource,
141            current_lang: RwLock::new(default_lang),
142        }
143    }
144}
145
146#[cfg(feature = "minimal")]
147impl Localizer for EsFluentLanguageLocalizer {
148    fn select_language(&self, lang: &LanguageIdentifier) -> Result<(), LocalizationError> {
149        *self.current_lang.write().expect("lock poisoned") = lang.clone();
150        Ok(())
151    }
152
153    fn localize<'a>(
154        &self,
155        id: &str,
156        args: Option<&HashMap<&str, FluentValue<'a>>>,
157    ) -> Option<String> {
158        let lang = self.current_lang.read().expect("lock poisoned").clone();
159        let mut bundle = FluentBundle::new(vec![lang]);
160        if let Err(err) = bundle.add_resource(self.resource.clone()) {
161            tracing::error!("Failed to add es-fluent-lang resource: {:?}", err);
162            return None;
163        }
164
165        let message = bundle.get_message(id)?;
166        let pattern = message.value()?;
167        let mut errors = Vec::new();
168
169        let fluent_args = args.map(|args| {
170            let mut fluent_args = FluentArgs::new();
171            for (key, value) in args {
172                fluent_args.set(*key, value.clone());
173            }
174            fluent_args
175        });
176
177        let formatted = bundle.format_pattern(pattern, fluent_args.as_ref(), &mut errors);
178
179        if errors.is_empty() {
180            Some(formatted.into_owned())
181        } else {
182            tracing::error!(
183                "Formatting errors while localizing '{}' from es-fluent-lang: {:?}",
184                id,
185                errors
186            );
187            None
188        }
189    }
190}
191
192#[cfg(not(feature = "minimal"))]
193struct EsFluentLanguageLocalizer {
194    resources: RwLock<HashMap<LanguageIdentifier, Arc<FluentResource>>>,
195    current_lang: RwLock<Option<LanguageIdentifier>>,
196}
197
198#[cfg(not(feature = "minimal"))]
199impl EsFluentLanguageLocalizer {
200    fn new(default_lang: LanguageIdentifier) -> Self {
201        let localizer = Self {
202            resources: RwLock::new(HashMap::new()),
203            current_lang: RwLock::new(None),
204        };
205        let _ = localizer.set_language(&default_lang);
206        localizer
207    }
208
209    fn set_language(&self, lang: &LanguageIdentifier) -> Result<(), LocalizationError> {
210        let resolved = resolve_language(lang)
211            .ok_or_else(|| LocalizationError::LanguageNotSupported(lang.clone()))?;
212        let _ = self.load_resource(&resolved)?;
213        *self.current_lang.write().expect("lock poisoned") = Some(resolved);
214        Ok(())
215    }
216
217    fn load_resource(
218        &self,
219        lang: &LanguageIdentifier,
220    ) -> Result<Arc<FluentResource>, LocalizationError> {
221        if let Some(resource) = self
222            .resources
223            .read()
224            .expect("lock poisoned")
225            .get(lang)
226            .cloned()
227        {
228            return Ok(resource);
229        }
230
231        let path = format!("{}/{}", lang, I18N_RESOURCE_NAME);
232        let file = EsFluentLangAssets::get(&path)
233            .ok_or_else(|| LocalizationError::LanguageNotSupported(lang.clone()))?;
234        let content = match String::from_utf8(file.data.to_vec()) {
235            Ok(content) => content,
236            Err(err) => {
237                tracing::error!("Invalid UTF-8 in embedded file '{}': {}", path, err);
238                String::from_utf8_lossy(err.as_bytes()).into_owned()
239            },
240        };
241        let resource = FluentResource::try_new(content)
242            .map_err(|(_, errs)| LocalizationError::FluentParseError(errs))?;
243        let resource = Arc::new(resource);
244        self.resources
245            .write()
246            .expect("lock poisoned")
247            .insert(lang.clone(), resource.clone());
248        Ok(resource)
249    }
250}
251
252#[cfg(not(feature = "minimal"))]
253impl Localizer for EsFluentLanguageLocalizer {
254    fn select_language(&self, lang: &LanguageIdentifier) -> Result<(), LocalizationError> {
255        self.set_language(lang)
256    }
257
258    fn localize<'a>(
259        &self,
260        id: &str,
261        args: Option<&HashMap<&str, FluentValue<'a>>>,
262    ) -> Option<String> {
263        let lang = self.current_lang.read().expect("lock poisoned").clone()?;
264        let resource = match self.load_resource(&lang) {
265            Ok(resource) => resource,
266            Err(err) => {
267                tracing::error!("Failed to load es-fluent-lang resource: {}", err);
268                return None;
269            },
270        };
271
272        let mut bundle = FluentBundle::new(vec![lang]);
273        if let Err(err) = bundle.add_resource(resource) {
274            tracing::error!("Failed to add es-fluent-lang resource: {:?}", err);
275            return None;
276        }
277
278        let message = bundle.get_message(id)?;
279        let pattern = message.value()?;
280        let mut errors = Vec::new();
281
282        let fluent_args = args.map(|args| {
283            let mut fluent_args = FluentArgs::new();
284            for (key, value) in args {
285                fluent_args.set(*key, value.clone());
286            }
287            fluent_args
288        });
289
290        let formatted = bundle.format_pattern(pattern, fluent_args.as_ref(), &mut errors);
291
292        if errors.is_empty() {
293            Some(formatted.into_owned())
294        } else {
295            tracing::error!(
296                "Formatting errors while localizing '{}' from es-fluent-lang: {:?}",
297                id,
298                errors
299            );
300            None
301        }
302    }
303}
304
305inventory::submit! {
306    &EsFluentLanguageModule as &dyn I18nModule
307}
308
309#[cfg(all(feature = "bevy", feature = "minimal"))]
310mod bevy_support {
311    use super::*;
312    use es_fluent_manager_core::StaticI18nResource;
313    use std::sync::Arc;
314
315    struct EsFluentLangStaticResource;
316
317    static STATIC_RESOURCE: EsFluentLangStaticResource = EsFluentLangStaticResource;
318
319    impl StaticI18nResource for EsFluentLangStaticResource {
320        fn domain(&self) -> &'static str {
321            "es-fluent-lang"
322        }
323
324        fn resource(&self) -> Arc<FluentResource> {
325            embedded_resource()
326        }
327    }
328
329    inventory::submit! {
330        &STATIC_RESOURCE as &dyn StaticI18nResource
331    }
332}
333
334#[cfg(all(feature = "bevy", not(feature = "minimal")))]
335mod bevy_support {
336    use super::*;
337    use es_fluent_manager_core::StaticI18nResource;
338    use std::sync::Arc;
339
340    struct EsFluentLangStaticResource {
341        locale: &'static str,
342        language: OnceLock<Option<LanguageIdentifier>>,
343        resource: OnceLock<Option<Arc<FluentResource>>>,
344    }
345
346    impl EsFluentLangStaticResource {
347        const fn new(locale: &'static str) -> Self {
348            Self {
349                locale,
350                language: OnceLock::new(),
351                resource: OnceLock::new(),
352            }
353        }
354
355        fn language(&self) -> Option<&LanguageIdentifier> {
356            self.language
357                .get_or_init(|| self.locale.parse().ok())
358                .as_ref()
359        }
360
361        fn load_resource(&self) -> Option<Arc<FluentResource>> {
362            let path = format!("{}/{}", self.locale, I18N_RESOURCE_NAME);
363            let resource = self.resource.get_or_init(|| {
364                let file = EsFluentLangAssets::get(&path)?;
365                let content = match String::from_utf8(file.data.to_vec()) {
366                    Ok(content) => content,
367                    Err(err) => {
368                        tracing::error!("Invalid UTF-8 in embedded file '{}': {}", path, err);
369                        String::from_utf8_lossy(err.as_bytes()).into_owned()
370                    },
371                };
372                let resource = match FluentResource::try_new(content) {
373                    Ok(resource) => resource,
374                    Err((_, errs)) => {
375                        tracing::error!(
376                            "Failed to parse fluent resource from '{}': {:?}",
377                            path,
378                            errs
379                        );
380                        return None;
381                    },
382                };
383                Some(Arc::new(resource))
384            });
385            resource.clone()
386        }
387    }
388
389    impl StaticI18nResource for EsFluentLangStaticResource {
390        fn domain(&self) -> &'static str {
391            "es-fluent-lang"
392        }
393
394        fn matches_language(&self, lang: &LanguageIdentifier) -> bool {
395            let Some(resolved) = resolve_language(lang) else {
396                return false;
397            };
398            let Some(candidate) = self.language() else {
399                return false;
400            };
401            if candidate != &resolved {
402                return false;
403            }
404            self.load_resource().is_some()
405        }
406
407        fn resource(&self) -> Arc<FluentResource> {
408            self.load_resource().unwrap_or_else(|| {
409                Arc::new(
410                    FluentResource::try_new(String::new())
411                        .expect("Empty fluent resource should parse"),
412                )
413            })
414        }
415    }
416
417    include!(concat!(
418        env!("OUT_DIR"),
419        "/es_fluent_lang_static_resources.rs"
420    ));
421}