diff --git a/Cargo.lock b/Cargo.lock index 14e1347..8cb6741 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -176,6 +176,12 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fea41bba32d969b513997752735605054bc0dfa92b4c56bf1189f2e174be7a10" +[[package]] +name = "either" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e78d4f1cc4ae33bbfc157ed5d5a5ef3bc29227303d595861deb238fcec4e9457" + [[package]] name = "encoding" version = "0.2.33" @@ -388,6 +394,7 @@ dependencies = [ "gettext", "i18n-embed-impl", "intl-memoizer", + "itertools", "lazy_static", "locale_config", "log", @@ -467,6 +474,15 @@ dependencies = [ "unic-langid", ] +[[package]] +name = "itertools" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9a9d19fa1e79b6215ff29b9d6880b706147f16e9b1dbb1e4e5947b5b02bc5e3" +dependencies = [ + "either", +] + [[package]] name = "js-sys" version = "0.3.56" diff --git a/i18n-embed/Cargo.toml b/i18n-embed/Cargo.toml index 9f618b0..c686e1b 100644 --- a/i18n-embed/Cargo.toml +++ b/i18n-embed/Cargo.toml @@ -24,6 +24,7 @@ fluent-syntax = { version = "0.11", optional = true } gettext_system = { package = "gettext", version = "0.4", optional = true } i18n-embed-impl = { version = "0.8", path = "./i18n-embed-impl", optional = true } intl-memoizer = "0.5" +itertools = "0.10" lazy_static = "1.4" locale_config = { version = "0.3", optional = true } log = "0.4" diff --git a/i18n-embed/i18n/ftl/en-GB/test.ftl b/i18n-embed/i18n/ftl/en-GB/test.ftl index 996517e..28543b9 100644 --- a/i18n-embed/i18n/ftl/en-GB/test.ftl +++ b/i18n-embed/i18n/ftl/en-GB/test.ftl @@ -1,3 +1,4 @@ hello-world = Hello World Localisation! only-gb = only GB -different-args = this message has {$different} {$args} in different languages \ No newline at end of file +only-gb-args = Hello {$userName}! +different-args = this message has {$different} {$args} in different languages diff --git a/i18n-embed/i18n/ftl/en-US/test.ftl b/i18n-embed/i18n/ftl/en-US/test.ftl index 1288029..a1f145b 100644 --- a/i18n-embed/i18n/ftl/en-US/test.ftl +++ b/i18n-embed/i18n/ftl/en-US/test.ftl @@ -2,6 +2,7 @@ hello-world = Hello World Localization! only-us = only US only-ru = only RU only-gb = only GB (US Version) +only-gb-args = Hello {$userName}! (US Version) different-args = this message has different {$arg}s in different languages isolation-chars = inject a { $thing } here multi-line = @@ -19,4 +20,4 @@ multi-line-args = { $argTwo } - Finished! \ No newline at end of file + Finished! diff --git a/i18n-embed/src/fluent.rs b/i18n-embed/src/fluent.rs index d376506..b758dfc 100644 --- a/i18n-embed/src/fluent.rs +++ b/i18n-embed/src/fluent.rs @@ -16,13 +16,6 @@ use parking_lot::RwLock; use std::{borrow::Cow, collections::HashMap, fmt::Debug, sync::Arc}; use unic_langid::LanguageIdentifier; -lazy_static::lazy_static! { - static ref CURRENT_LANGUAGE: RwLock = { - let language = LanguageIdentifier::default(); - RwLock::new(language) - }; -} - struct LanguageBundle { language: LanguageIdentifier, bundle: FluentBundle, IntlLangMemoizer>, @@ -59,6 +52,9 @@ impl Debug for LanguageBundle { struct LanguageConfig { current_language: LanguageIdentifier, language_bundles: Vec, + /// This maps a `LanguageIdentifier` to the index inside the + /// `language_bundles` vector. + language_map: HashMap, } /// [LanguageLoader] implemenation for the `fluent` localization @@ -85,6 +81,7 @@ impl FluentLanguageLoader { let config = LanguageConfig { current_language: fallback_language.clone(), language_bundles: Vec::new(), + language_map: HashMap::new(), }; Self { @@ -106,40 +103,114 @@ impl FluentLanguageLoader { /// Get a localized message referenced by the `message_id`. pub fn get(&self, message_id: &str) -> String { - self.get_args_concrete(message_id, HashMap::new()) + self.get_args_fluent(message_id, None) } /// A non-generic version of [FluentLanguageLoader::get_args()]. - pub fn get_args_concrete<'source>( + pub fn get_args_concrete<'args>( &self, message_id: &str, - args: HashMap<&'source str, FluentValue<'source>>, + args: HashMap<&'args str, FluentValue<'args>>, ) -> String { - let args_option = if args.is_empty() { - None - } else { - let mut fluent_args = FluentArgs::with_capacity(args.len()); + self.get_args_fluent(message_id, hash_map_to_fluent_args(args).as_ref()) + } - for (key, value) in args { - fluent_args.set(key, value); - } + /// A non-generic version of [FluentLanguageLoader::get_args()] + /// accepting [FluentArgs] instead of a [HashMap]. + pub fn get_args_fluent<'args>( + &self, + message_id: &str, + args: Option<&'args FluentArgs<'args>>, + ) -> String { + let language_config = self.language_config.read(); + self._get( + language_config.language_bundles.iter(), + &language_config.current_language, + message_id, + args, + ) + } - Some(fluent_args) - }; + /// Get a localized message referenced by the `message_id`, and + /// formatted with the specified `args`. + pub fn get_args<'a, S, V>(&self, id: &str, args: HashMap) -> String + where + S: Into> + Clone, + V: Into> + Clone, + { + self.get_args_fluent(id, hash_map_to_fluent_args(args).as_ref()) + } - self.get_args_fluent(message_id, args_option.as_ref()) + /// Get a localized message referenced by the `message_id`. + pub fn get_lang(&self, lang: &[&LanguageIdentifier], message_id: &str) -> String { + self.get_lang_args_fluent(lang, message_id, None) } - /// A non-generic version of [FluentLanguageLoader::get_args()] + /// A non-generic version of [FluentLanguageLoader::get_lang_args()]. + pub fn get_lang_args_concrete<'source>( + &self, + lang: &[&LanguageIdentifier], + message_id: &str, + args: HashMap<&'source str, FluentValue<'source>>, + ) -> String { + self.get_lang_args_fluent(lang, message_id, hash_map_to_fluent_args(args).as_ref()) + } + + /// A non-generic version of [FluentLanguageLoader::get_lang_args()] /// accepting [FluentArgs] instead of a [HashMap]. - pub fn get_args_fluent<'args>( + pub fn get_lang_args_fluent<'args>( &self, + lang: &[&LanguageIdentifier], message_id: &str, args: Option<&'args FluentArgs<'args>>, ) -> String { + let current_language = if lang.is_empty() { + &self.fallback_language + } else { + &lang[0] + }; + let fallback_language = if lang.contains(&&self.fallback_language) { + None + } else { + Some(&self.fallback_language) + }; let config_lock = self.language_config.read(); + let language_bundles = lang + .iter() + .chain(fallback_language.as_ref().into_iter()) + .filter_map(|id| { + config_lock + .language_map + .get(id) + .map(|idx| &config_lock.language_bundles[*idx]) + }); + self._get(language_bundles, current_language, message_id, args) + } + + /// Get a localized message for the given language identifiers, referenced + /// by the `message_id` and formatted with the specified `args`. + pub fn get_lang_args<'a, S, V>( + &self, + lang: &[&LanguageIdentifier], + id: &str, + args: HashMap, + ) -> String + where + S: Into> + Clone, + V: Into> + Clone, + { + let fluent_args = hash_map_to_fluent_args(args); + self.get_lang_args_fluent(lang, id, fluent_args.as_ref()) + } - config_lock.language_bundles.iter().filter_map(|language_bundle| { + fn _get<'a, 'args>( + &'a self, + language_bundles: impl Iterator, + current_language: &LanguageIdentifier, + message_id: &str, + args: Option<&'args FluentArgs<'args>>, + ) -> String { + language_bundles.filter_map(|language_bundle| { language_bundle .bundle .get_message(message_id) @@ -151,57 +222,24 @@ impl FluentLanguageLoader { log::error!( target:"i18n_embed::fluent", "Failed to format a message for language \"{}\" and id \"{}\".\nErrors\n{:?}.", - &config_lock.current_language, message_id, errors + current_language, message_id, errors ) } - value.into() - }) + }) }) .next() .unwrap_or_else(|| { log::error!( target:"i18n_embed::fluent", "Unable to find localization for language \"{}\" and id \"{}\".", - config_lock.current_language, + current_language, message_id ); format!("No localization for id: \"{}\"", message_id) }) } - /// Get a localized message referenced by the `message_id`, and - /// formatted with the specified `args`. - pub fn get_args<'a, S, V>(&self, id: &str, args: HashMap) -> String - where - S: Into> + Clone, - V: Into> + Clone, - { - let mut keys: Vec> = Vec::new(); - - let mut map: HashMap<&str, FluentValue<'_>> = HashMap::with_capacity(args.len()); - - let mut values = Vec::new(); - - for (key, value) in args.into_iter() { - keys.push(key.into()); - values.push(value.into()); - } - - for (i, key) in keys.iter().rev().enumerate() { - let value = values.pop().unwrap_or_else(|| { - panic!( - "expected a value corresponding with key \"{}\" at position {}", - key, i - ) - }); - - map.insert(&*key, value); - } - - self.get_args_concrete(id, map) - } - /// Returns true if a message with the specified `message_id` is /// available in any of the languages currently loaded (including /// the fallback language). @@ -371,10 +409,32 @@ impl LanguageLoader for FluentLanguageLoader { } let mut config_lock = self.language_config.write(); - config_lock.language_bundles = language_bundles; config_lock.current_language = current_language.clone(); + config_lock.language_bundles = language_bundles; + config_lock.language_map = config_lock + .language_bundles + .iter() + .enumerate() + .map(|(i, language_bundle)| (language_bundle.language.clone(), i)) + .collect(); drop(config_lock); Ok(()) } } + +fn hash_map_to_fluent_args<'args, S, V>(map: HashMap) -> Option> +where + S: Into> + Clone, + V: Into> + Clone, +{ + if map.is_empty() { + None + } else { + let mut args = FluentArgs::with_capacity(map.len()); + for (key, value) in map { + args.set(key, value); + } + Some(args) + } +} diff --git a/i18n-embed/tests/loader.rs b/i18n-embed/tests/loader.rs index 4b90aea..396fb67 100644 --- a/i18n-embed/tests/loader.rs +++ b/i18n-embed/tests/loader.rs @@ -188,6 +188,95 @@ mod fluent { msg ); } + + #[test] + fn get_lang_default_fallback() { + setup(); + let ru: LanguageIdentifier = "ru".parse().unwrap(); + let en_gb: LanguageIdentifier = "en-GB".parse().unwrap(); + let en_us: LanguageIdentifier = "en-US".parse().unwrap(); + let loader = FluentLanguageLoader::new("test", en_us); + + loader + .load_languages(&Localizations, &[&ru, &en_gb]) + .unwrap(); + + let msg = loader.get_lang(&[&ru], "only-ru"); + assert_eq!("только русский", msg); + + let msg = loader.get_lang(&[&ru], "only-gb"); + assert_eq!("only GB (US Version)", msg); + } + + #[test] + fn get_lang_args_default_fallback() { + setup(); + let ru: LanguageIdentifier = "ru".parse().unwrap(); + let en_gb: LanguageIdentifier = "en-GB".parse().unwrap(); + let en_us: LanguageIdentifier = "en-US".parse().unwrap(); + let loader = FluentLanguageLoader::new("test", en_us); + + loader + .load_languages(&Localizations, &[&ru, &en_gb]) + .unwrap(); + + let args = maplit::hashmap! { + "argOne" => "1", + "argTwo" => "2", + }; + + let msg = loader.get_lang_args(&[&ru], "multi-line-args", args); + assert_eq!( + "Это многострочное сообщение с параметрами.\n\n\ + \u{2068}1\u{2069}\n\n\ + Это многострочное сообщение с параметрами.\n\n\ + \u{2068}2\u{2069}\n\n\ + Законченный!", + msg + ); + } + + #[test] + fn get_lang_custom_fallback() { + setup(); + let ru: LanguageIdentifier = "ru".parse().unwrap(); + let en_gb: LanguageIdentifier = "en-GB".parse().unwrap(); + let en_us: LanguageIdentifier = "en-US".parse().unwrap(); + let loader = FluentLanguageLoader::new("test", en_us); + + loader + .load_languages(&Localizations, &[&ru, &en_gb]) + .unwrap(); + + let msg = loader.get_lang(&[&ru, &en_gb], "only-gb"); + assert_eq!("only GB", msg); + + let msg = loader.get_lang(&[&ru, &en_gb], "only-us"); + assert_eq!("only US", msg); + } + + #[test] + fn get_lang_args_custom_fallback() { + setup(); + let ru: LanguageIdentifier = "ru".parse().unwrap(); + let en_gb: LanguageIdentifier = "en-GB".parse().unwrap(); + let en_us: LanguageIdentifier = "en-US".parse().unwrap(); + let loader = FluentLanguageLoader::new("test", en_us); + + loader + .load_languages(&Localizations, &[&ru, &en_gb]) + .unwrap(); + + let args = maplit::hashmap! { + "userName" => "username", + }; + + let msg = loader.get_lang_args(&[&ru], "only-gb-args", args.clone()); + assert_eq!("Hello \u{2068}username\u{2069}! (US Version)", msg); + + let msg = loader.get_lang_args(&[&ru, &en_gb], "only-gb-args", args.clone()); + assert_eq!("Hello \u{2068}username\u{2069}!", msg); + } } #[cfg(feature = "gettext-system")]