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}