1mod error;
2mod raw;
3
4use error::ResourceErr;
5
6#[cfg(any(feature = "bin", feature = "stat", test))]
7pub mod analysis;
8
9use fluent_bundle::{FluentResource, bundle::FluentBundle};
10use intl_memoizer::concurrent::IntlLangMemoizer;
11use unic_langid::LanguageIdentifier;
12
13use deunicode::deunicode;
14
15use hashbrown::HashMap;
16use serde::{Deserialize, Serialize};
17use std::{borrow::Cow, io};
18
19use assets::{
20 AssetExt, AssetHandle, AssetReadGuard, ReloadWatcher, SharedString, source::DirEntry,
21};
22use common_assets as assets;
23use common_i18n::{Content, LocalizationArg};
24use tracing::warn;
25
26pub use fluent::{FluentValue, fluent_args};
28pub use fluent_bundle::FluentArgs;
29
30pub const REFERENCE_LANG: &str = "en";
33
34#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
36pub struct LanguageMetadata {
37 pub language_name: String,
39
40 pub language_identifier: String,
47}
48
49#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
51pub struct Font {
52 pub asset_key: String,
54
55 scale_ratio: f32,
57}
58
59impl Font {
60 #[must_use]
62 pub fn scale(&self, value: u32) -> u32 { (value as f32 * self.scale_ratio).round() as u32 }
63}
64
65pub type Fonts = HashMap<String, Font>;
67
68struct Language {
70 pub(crate) bundle: FluentBundle<FluentResource, IntlLangMemoizer>,
72
73 pub(crate) fonts: Fonts,
75 pub(crate) metadata: LanguageMetadata,
76 pub(crate) convert_utf8_to_ascii: bool,
77}
78
79impl Language {
80 fn try_msg<'a>(&'a self, key: &str, args: Option<&'a FluentArgs>) -> Option<Cow<'a, str>> {
81 let bundle = &self.bundle;
82 let msg = bundle.get_message(key)?;
83 let mut errs = Vec::new();
84 let msg = bundle.format_pattern(msg.value()?, args, &mut errs);
85 for err in errs {
86 tracing::error!("err: {err} for {key}");
87 }
88
89 let msg = if self.convert_utf8_to_ascii {
90 deunicode(&msg).into()
91 } else {
92 msg
93 };
94
95 Some(msg)
96 }
97
98 fn try_attr<'a>(
99 &'a self,
100 key: &str,
101 attr: &str,
102 args: Option<&'a FluentArgs>,
103 ) -> Option<Cow<'a, str>> {
104 let bundle = &self.bundle;
105 let msg = bundle.get_message(key)?;
106 let attr = msg.get_attribute(attr)?;
107 let attr = attr.value();
108
109 let mut errs = Vec::new();
110 let msg = bundle.format_pattern(attr, args, &mut errs);
111 for err in errs {
112 tracing::error!("err: {err} for {key}");
113 }
114
115 let msg = if self.convert_utf8_to_ascii {
116 deunicode(&msg).into()
117 } else {
118 msg
119 };
120
121 Some(msg)
122 }
123
124 fn try_variation<'a>(
127 &'a self,
128 key: &str,
129 seed: u16,
130 args: Option<&'a FluentArgs>,
131 ) -> Option<Cow<'a, str>> {
132 let bundle = &self.bundle;
133 let msg = bundle.get_message(key)?;
134 let mut attrs = msg.attributes();
135
136 let mut errs = Vec::new();
137
138 let msg = if attrs.len() != 0 {
139 let idx = usize::from(seed) % attrs.len();
140 let variation = attrs.nth(idx).unwrap();
156 bundle.format_pattern(variation.value(), args, &mut errs)
157 } else {
158 bundle.format_pattern(msg.value()?, args, &mut errs)
160 };
161
162 for err in errs {
163 tracing::error!("err: {err} for {key}");
164 }
165
166 let msg = if self.convert_utf8_to_ascii {
167 deunicode(&msg).into()
168 } else {
169 msg
170 };
171
172 Some(msg)
173 }
174}
175impl assets::Compound for Language {
176 fn load(cache: assets::AnyCache, path: &SharedString) -> Result<Self, assets::BoxedError> {
177 let manifest = cache
178 .load::<raw::Manifest>(&[path, ".", "_manifest"].concat())?
179 .cloned();
180 let raw::Manifest {
181 convert_utf8_to_ascii,
182 fonts,
183 metadata,
184 } = manifest;
185
186 let lang_id: LanguageIdentifier = metadata.language_identifier.parse()?;
187 let mut bundle = FluentBundle::new_concurrent(vec![lang_id]);
188
189 for id in cache.load_rec_dir::<raw::Resource>(path)?.read().ids() {
191 match cache.load(id) {
192 Ok(handle) => {
193 let source: &raw::Resource = &handle.read();
194 let src = source.src.clone();
195
196 let resource = FluentResource::try_new(src).map_err(|(_ast, errs)| {
197 ResourceErr::parsing_error(errs, id.to_string(), &source.src)
198 })?;
199
200 bundle
201 .add_resource(resource)
202 .map_err(|e| ResourceErr::BundleError(format!("{e:?}")))?;
203 },
204 Err(err) => {
205 warn!("Unable to load asset {id}, error={err:?}");
207 },
208 }
209 }
210
211 bundle.set_use_isolating(false);
215
216 Ok(Self {
217 bundle,
218 fonts,
219 metadata,
220 convert_utf8_to_ascii,
221 })
222 }
223}
224
225#[derive(Copy, Clone)]
228pub struct LocalizationHandle {
229 active: AssetHandle<Language>,
230 watcher: ReloadWatcher,
231 fallback: Option<AssetHandle<Language>>,
232 pub use_english_fallback: bool,
233}
234
235pub type Localization = LocalizationGuard;
238
239pub struct LocalizationGuard {
242 active: AssetReadGuard<Language>,
243 fallback: Option<AssetReadGuard<Language>>,
244}
245
246impl LocalizationGuard {
247 pub fn try_fallback_msg(&self, key: &str) -> Option<Cow<str>> {
249 self.fallback.as_ref().and_then(|fb| fb.try_msg(key, None))
250 }
251
252 pub fn try_msg(&self, key: &str) -> Option<Cow<str>> {
257 self.active
258 .try_msg(key, None)
259 .or_else(|| self.try_fallback_msg(key))
260 }
261
262 pub fn get_msg(&self, key: &str) -> Cow<str> {
269 self.try_msg(key)
274 .unwrap_or_else(|| Cow::Owned(key.to_owned()))
275 }
276
277 pub fn try_msg_ctx<'a>(&'a self, key: &str, args: &'a FluentArgs) -> Option<Cow<'static, str>> {
282 self.active
288 .try_msg(key, Some(args))
289 .or_else(|| {
290 self.fallback
291 .as_ref()
292 .and_then(|fb| fb.try_msg(key, Some(args)))
293 })
294 .map(|res| Cow::Owned(res.into_owned()))
295 }
296
297 pub fn get_msg_ctx<'a>(&'a self, key: &str, args: &'a FluentArgs) -> Cow<'static, str> {
304 self.try_msg_ctx(key, args)
305 .unwrap_or_else(|| Cow::Owned(key.to_owned()))
306 }
307
308 pub fn try_variation(&self, key: &str, seed: u16) -> Option<Cow<str>> {
316 self.active.try_variation(key, seed, None).or_else(|| {
317 self.fallback
318 .as_ref()
319 .and_then(|fb| fb.try_variation(key, seed, None))
320 })
321 }
322
323 pub fn get_variation(&self, key: &str, seed: u16) -> Cow<str> {
333 self.try_variation(key, seed)
334 .unwrap_or_else(|| Cow::Owned(key.to_owned()))
335 }
336
337 pub fn try_variation_ctx<'a>(
346 &'a self,
347 key: &str,
348 seed: u16,
349 args: &'a FluentArgs,
350 ) -> Option<Cow<'a, str>> {
351 self.active
352 .try_variation(key, seed, Some(args))
353 .or_else(|| {
354 self.fallback
355 .as_ref()
356 .and_then(|fb| fb.try_variation(key, seed, Some(args)))
357 })
358 }
359
360 fn get_content_for_lang(lang: &Language, content: &Content) -> Result<String, String> {
368 match content {
369 Content::Plain(text) => Ok(text.clone()),
370 Content::Key(key) => lang
371 .try_msg(key, None)
372 .map(Cow::into_owned)
373 .ok_or_else(|| key.to_string()),
374 Content::Attr(key, attr) => lang
375 .try_attr(key, attr, None)
376 .map(Cow::into_owned)
377 .ok_or_else(|| format!("{key}.{attr}")),
378 Content::Localized { key, seed, args } => {
379 let mut is_arg_failure = false;
381
382 let mut fargs = FluentArgs::new();
383 for (k, arg) in args {
384 let arg_val = match arg {
385 LocalizationArg::Content(content) => {
386 let arg_res = Self::get_content_for_lang(lang, content)
387 .unwrap_or_else(|broken_text| {
388 is_arg_failure = true;
389 broken_text
390 })
391 .into();
392
393 FluentValue::String(arg_res)
394 },
395 LocalizationArg::Nat(n) => FluentValue::from(n),
396 };
397 fargs.set(k, arg_val);
398 }
399
400 lang.try_variation(key, *seed, Some(&fargs))
401 .map(Cow::into_owned)
402 .ok_or_else(|| key.clone())
403 .and_then(|text| if is_arg_failure { Err(text) } else { Ok(text) })
404 },
405 }
406 }
407
408 pub fn get_content(&self, content: &Content) -> String {
445 match Self::get_content_for_lang(&self.active, content) {
446 Ok(text) => text,
447 Err(broken_text) => self
452 .fallback
453 .as_ref()
454 .and_then(|fb| Self::get_content_for_lang(fb, content).ok())
455 .unwrap_or(broken_text),
456 }
457 }
458
459 pub fn get_content_fallback(&self, content: &Content) -> String {
460 self.fallback
461 .as_ref()
462 .map(|fb| Self::get_content_for_lang(fb, content))
463 .transpose()
464 .map(|msg| msg.unwrap_or_default())
465 .unwrap_or_else(|e| e)
466 }
467
468 pub fn get_variation_ctx<'a>(
479 &'a self,
480 key: &str,
481 seed: u16,
482 args: &'a FluentArgs,
483 ) -> Cow<'a, str> {
484 self.try_variation_ctx(key, seed, args)
485 .unwrap_or_else(|| Cow::Owned(key.to_owned()))
486 }
487
488 pub fn try_attr(&self, key: &str, attr: &str) -> Option<Cow<str>> {
493 self.active.try_attr(key, attr, None).or_else(|| {
494 self.fallback
495 .as_ref()
496 .and_then(|fb| fb.try_attr(key, attr, None))
497 })
498 }
499
500 pub fn get_attr(&self, key: &str, attr: &str) -> Cow<str> {
507 self.try_attr(key, attr)
508 .unwrap_or_else(|| Cow::Owned(format!("{key}.{attr}")))
509 }
510
511 pub fn try_attr_ctx(
516 &self,
517 key: &str,
518 attr: &str,
519 args: &FluentArgs,
520 ) -> Option<Cow<'static, str>> {
521 self.active
525 .try_attr(key, attr, Some(args))
526 .or_else(|| {
527 self.fallback
528 .as_ref()
529 .and_then(|fb| fb.try_attr(key, attr, Some(args)))
530 })
531 .map(|res| Cow::Owned(res.into_owned()))
532 }
533
534 pub fn get_attr_ctx(&self, key: &str, attr: &str, args: &FluentArgs) -> Cow<'static, str> {
541 self.try_attr_ctx(key, attr, args)
542 .unwrap_or_else(|| Cow::Owned(format!("{key}.{attr}")))
543 }
544
545 #[must_use]
546 pub fn fonts(&self) -> &Fonts { &self.active.fonts }
547
548 #[must_use]
549 pub fn metadata(&self) -> &LanguageMetadata { &self.active.metadata }
550}
551
552impl LocalizationHandle {
553 pub fn set_english_fallback(&mut self, use_english_fallback: bool) {
554 self.use_english_fallback = use_english_fallback;
555 }
556
557 #[must_use]
558 pub fn read(&self) -> LocalizationGuard {
559 LocalizationGuard {
560 active: self.active.read(),
561 fallback: if self.use_english_fallback {
562 self.fallback.map(|f| f.read())
563 } else {
564 None
565 },
566 }
567 }
568
569 pub fn load(specifier: &str) -> Result<Self, assets::Error> {
572 let default_key = ["voxygen.i18n.", REFERENCE_LANG].concat();
573 let language_key = ["voxygen.i18n.", specifier].concat();
574 let is_default = language_key == default_key;
575 let active = Language::load(&language_key)?;
576 Ok(Self {
577 active,
578 watcher: active.reload_watcher(),
579 fallback: if is_default {
580 None
581 } else {
582 Some(Language::load(&default_key)?)
583 },
584 use_english_fallback: false,
585 })
586 }
587
588 #[must_use]
589 pub fn load_expect(specifier: &str) -> Self {
590 Self::load(specifier).expect("Can't load language files")
591 }
592
593 pub fn reloaded(&mut self) -> bool { self.watcher.reloaded() }
594}
595
596struct FindManifests;
597
598impl assets::DirLoadable for FindManifests {
599 fn select_ids(
600 cache: assets::AnyCache,
601 specifier: &SharedString,
602 ) -> io::Result<Vec<SharedString>> {
603 use assets::Source;
604
605 let mut specifiers = Vec::new();
606
607 let source = cache.raw_source();
608 source.read_dir(specifier, &mut |entry| {
609 if let DirEntry::Directory(spec) = entry {
610 let manifest_spec = [spec, ".", "_manifest"].concat();
611
612 if source.exists(DirEntry::File(&manifest_spec, "ron")) {
613 specifiers.push(manifest_spec.into());
614 }
615 }
616 })?;
617
618 Ok(specifiers)
619 }
620}
621
622#[derive(Clone, Debug)]
623struct LocalizationList(Vec<LanguageMetadata>);
624
625impl assets::Compound for LocalizationList {
626 fn load(cache: assets::AnyCache, specifier: &SharedString) -> Result<Self, assets::BoxedError> {
627 let languages = assets::load_rec_dir::<FindManifests>(specifier)
629 .unwrap_or_else(|e| panic!("Failed to get manifests from {}: {:?}", specifier, e))
630 .read()
631 .ids()
632 .filter_map(|spec| cache.load::<raw::Manifest>(spec).ok())
633 .map(|localization| localization.read().metadata.clone())
634 .collect();
635
636 Ok(LocalizationList(languages))
637 }
638}
639
640#[must_use]
642pub fn list_localizations() -> Vec<LanguageMetadata> {
643 let LocalizationList(list) = LocalizationList::load_expect_cloned("voxygen.i18n");
644 list
645}
646
647#[cfg(test)]
648mod tests {
649 use super::*;
650
651 #[test]
652 fn check_localization_list() {
654 let list = list_localizations();
655 assert!(!list.is_empty());
656 }
657
658 #[test]
659 fn validate_reference_language() { let _ = LocalizationHandle::load_expect(REFERENCE_LANG); }
661
662 #[test]
663 fn validate_all_localizations() {
665 let list = list_localizations();
666 for meta in list {
667 let _ = LocalizationHandle::load_expect(&meta.language_identifier);
668 }
669 }
670
671 #[test]
672 fn test_strict_all_localizations() {
673 use analysis::{Language, ReferenceLanguage};
674 use assets::find_root;
675
676 let root = find_root().unwrap();
677 let i18n_directory = root.join("assets/voxygen/i18n");
678 let reference = ReferenceLanguage::at(&i18n_directory.join(REFERENCE_LANG));
679
680 let list = list_localizations();
681
682 for meta in list {
683 let code = meta.language_identifier;
684 let lang = Language {
685 code: code.clone(),
686 path: i18n_directory.join(code.clone()),
687 };
688 reference.compare_with(&lang);
691 }
692 }
693}