diff --git a/Cargo.lock b/Cargo.lock index 4a49a323..a3e44aaf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1414,6 +1414,17 @@ dependencies = [ "winapi", ] +[[package]] +name = "displaydoc" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "487585f4d0c6655fe74905e2504d8ad6908e4db67f744eb140876906c2f3175d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.33", +] + [[package]] name = "dotenvy" version = "0.15.7" @@ -1629,6 +1640,17 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8fcfdc7a0362c9f4444381a9e697c79d435fe65b52a37466fc2c1184cee9edc6" +[[package]] +name = "fixed_decimal" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5287d527037d0f35c8801880361eb38bb9bce194805350052c2a79538388faeb" +dependencies = [ + "displaydoc", + "smallvec", + "writeable", +] + [[package]] name = "flume" version = "0.10.14" @@ -2206,6 +2228,121 @@ dependencies = [ "cc", ] +[[package]] +name = "icu_list" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "880479cefad9670d4e07b832435539ceaa78b813bddc11fa504a0fd88871bea8" +dependencies = [ + "displaydoc", + "icu_list_data", + "icu_locid_transform", + "icu_provider", + "regex-automata 0.2.0", + "writeable", +] + +[[package]] +name = "icu_list_data" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f0874b78501946c343c1cfb07fcae7ceceddba47b906d94af5b2730433de7a0" + +[[package]] +name = "icu_locid" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56b72c6de0121c00da9828eb3e2603041d563788289bb15feba7c3331de71b5f" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_locid_transform" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49464337ec1e96a409e34be1d7215bdc20159193df2eb2a89fb94403996092ac" +dependencies = [ + "displaydoc", + "icu_locid", + "icu_locid_transform_data", + "icu_provider", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_locid_transform_data" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce5d8151979828a4645be945302e05c903cbb5c4a86a936965f7605bd5142e06" + +[[package]] +name = "icu_plurals" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce4d79d7c6240f9f27761ef9ac49d5c770f9c34a70a69c89310a763deb9e24d7" +dependencies = [ + "displaydoc", + "fixed_decimal", + "icu_locid", + "icu_locid_transform", + "icu_plurals_data", + "icu_provider", + "zerovec", +] + +[[package]] +name = "icu_plurals_data" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "587cb66266fc618b5593ab11a5fa8c44391e10006acf979ae176e6bd4ead6c3f" + +[[package]] +name = "icu_provider" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d3810a06fce5c900f8ace41b72abf8f6308f77c9e7647211aa5f121c0c9f43" +dependencies = [ + "displaydoc", + "icu_locid", + "icu_provider_macros", + "stable_deref_trait", + "tinystr", + "writeable", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_provider_adapters" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb43949fa871c2c79828575058971af57b0ac5e58c9e4fdb69f1f4a2f3f4e17" +dependencies = [ + "icu_locid", + "icu_locid_transform", + "icu_provider", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_provider_macros" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca9be8af0b117ccf1516251daab4c9137c012646a211c2a02d2f568ea3cd0df4" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.33", +] + [[package]] name = "id-arena" version = "2.2.1" @@ -2558,6 +2695,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "litemap" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77a1a2647d5b7134127971a6de0d533c49de2159167e7f259c427195f87168a1" + [[package]] name = "lock_api" version = "0.4.10" @@ -2859,12 +3002,20 @@ dependencies = [ name = "mas-i18n" version = "0.2.0" dependencies = [ + "camino", + "icu_list", + "icu_locid", + "icu_locid_transform", + "icu_plurals", + "icu_provider", + "icu_provider_adapters", "pad", "pest", "pest_derive", "serde", "serde_json", "thiserror", + "writeable", ] [[package]] @@ -4356,6 +4507,15 @@ dependencies = [ "regex-syntax 0.6.29", ] +[[package]] +name = "regex-automata" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9368763f5a9b804326f3af749e16f9abf378d227bcdee7634b13d8f17793782" +dependencies = [ + "memchr", +] + [[package]] name = "regex-automata" version = "0.3.8" @@ -5428,6 +5588,18 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" +[[package]] +name = "synstructure" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "285ba80e733fac80aa4270fbcdf83772a79b80aa35c97075320abfee4a915b06" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.33", + "unicode-xid", +] + [[package]] name = "target-lexicon" version = "0.12.11" @@ -5543,6 +5715,16 @@ dependencies = [ "time-core", ] +[[package]] +name = "tinystr" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b07bb54ef1f8ff27564b08b861144d3b8d40263efe07684f64987f4c0d044e3e" +dependencies = [ + "displaydoc", + "zerovec", +] + [[package]] name = "tinyvec" version = "1.6.0" @@ -6763,6 +6945,12 @@ dependencies = [ "url", ] +[[package]] +name = "writeable" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0af0c3d13faebf8dda0b5256fa7096a2d5ccb662f7b9f54a40fe201077ab1c2" + [[package]] name = "yaml-rust" version = "0.4.5" @@ -6778,12 +6966,79 @@ version = "1.0.0-rc.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1367295b8f788d371ce2dbc842c7b709c73ee1364d30351dd300ec2203b12377" +[[package]] +name = "yoke" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61e38c508604d6bbbd292dadb3c02559aa7fff6b654a078a36217cad871636e4" +dependencies = [ + "serde", + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5e19fb6ed40002bab5403ffa37e53e0e56f914a4450c8765f533018db1db35f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.33", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "655b0814c5c0b19ade497851070c640773304939a6c0fd5f5fb43da0696d05b7" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6a647510471d372f2e6c2e6b7219e44d8c574d24fdc11c610a61455782f18c3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.33", + "synstructure", +] + [[package]] name = "zeroize" version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a0956f1ba7c7909bfb66c2e9e4124ab6f6482560f6628b5aaeba39207c9aad9" +[[package]] +name = "zerovec" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1194130c5b155bf8ae50ab16c86ab758cd695cf9ad176d2f870b744cbdbb572e" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acabf549809064225ff8878baedc4ce3732ac3b07e7c7ce6e5c2ccdbc485c324" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.33", +] + [[package]] name = "zstd" version = "0.11.2+zstd.1.5.2" diff --git a/Cargo.toml b/Cargo.toml index 4622c1ff..56e59e87 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -47,6 +47,8 @@ version = "1.0.48" # Logging and tracing [workspace.dependencies.tracing] version = "0.1.37" +[workspace.dependencies.tracing-subscriber] +version = "0.3.17" # URL manipulation [workspace.dependencies.url] diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index ed55b6a6..973ff9a4 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -32,7 +32,7 @@ zeroize = "1.6.0" tracing.workspace = true tracing-appender = "0.2.2" -tracing-subscriber = { version = "0.3.17", features = ["env-filter"] } +tracing-subscriber = { workspace = true, features = ["env-filter"] } tracing-opentelemetry = "0.21.0" opentelemetry = { version = "0.20.0", features = ["trace", "metrics", "rt-tokio"] } opentelemetry-http = { version = "0.9.0", features = ["tokio", "hyper"] } diff --git a/crates/handlers/Cargo.toml b/crates/handlers/Cargo.toml index 1d7e2b76..9ed79e8e 100644 --- a/crates/handlers/Cargo.toml +++ b/crates/handlers/Cargo.toml @@ -81,7 +81,7 @@ oauth2-types = { path = "../oauth2-types" } [dev-dependencies] insta = "1.32.0" -tracing-subscriber = "0.3.17" +tracing-subscriber.workspace = true cookie_store = "0.20.0" [features] diff --git a/crates/i18n/Cargo.toml b/crates/i18n/Cargo.toml index 8267903b..f7da32f3 100644 --- a/crates/i18n/Cargo.toml +++ b/crates/i18n/Cargo.toml @@ -8,9 +8,17 @@ homepage.workspace = true repository.workspace = true [dependencies] +camino = "1.1.6" +icu_list = { version = "1.3.0", features = ["compiled_data", "std"] } +icu_locid = { version = "1.3.0", features = ["std"] } +icu_locid_transform = { version = "1.3.0", features = ["compiled_data", "std"] } +icu_plurals = { version = "1.3.0", features = ["compiled_data", "std"] } +icu_provider = { version = "1.3.0", features = ["std"] } +icu_provider_adapters = { version = "1.3.0", features = ["std"] } pad = "0.1.6" pest = "2.7.3" pest_derive = "2.7.3" serde.workspace = true serde_json.workspace = true -thiserror.workspace = true \ No newline at end of file +thiserror.workspace = true +writeable = "0.5.3" \ No newline at end of file diff --git a/crates/i18n/src/lib.rs b/crates/i18n/src/lib.rs index cc77b11d..728b07d2 100644 --- a/crates/i18n/src/lib.rs +++ b/crates/i18n/src/lib.rs @@ -17,3 +17,9 @@ pub mod sprintf; pub mod translations; +mod translator; + +pub use self::{ + sprintf::{Argument, ArgumentList, Message}, + translator::Translator, +}; diff --git a/crates/i18n/src/sprintf/argument.rs b/crates/i18n/src/sprintf/argument.rs index f54772b1..964b7487 100644 --- a/crates/i18n/src/sprintf/argument.rs +++ b/crates/i18n/src/sprintf/argument.rs @@ -24,11 +24,13 @@ pub struct List { } impl List { + /// Get an argument by its index. #[must_use] pub fn get_by_index(&self, index: usize) -> Option<&Value> { self.arguments.get(index) } + /// Get an argument by its name. #[must_use] pub fn get_by_name(&self, name: &str) -> Option<&Value> { self.name_index @@ -58,6 +60,7 @@ impl> FromIterator for List { } } +/// A single argument value. pub struct Argument { name: Option, value: Value, diff --git a/crates/i18n/src/sprintf/message.rs b/crates/i18n/src/sprintf/message.rs index 92a8a258..2ef09c3c 100644 --- a/crates/i18n/src/sprintf/message.rs +++ b/crates/i18n/src/sprintf/message.rs @@ -110,7 +110,7 @@ impl std::fmt::Display for TypeSpecifier { } } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq, Eq)] pub enum ArgumentReference { Indexed(usize), Named(String), @@ -125,7 +125,7 @@ impl std::fmt::Display for ArgumentReference { } } -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum PaddingSpecifier { Zero, Char(char), @@ -156,7 +156,7 @@ impl PaddingSpecifier { } } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq, Eq)] pub struct Placeholder { pub type_specifier: TypeSpecifier, pub requested_argument: Option, @@ -211,7 +211,7 @@ impl std::fmt::Display for Placeholder { } } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq, Eq)] pub struct Message { parts: Vec, } @@ -253,7 +253,7 @@ impl<'de> Deserialize<'de> for Message { } } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq, Eq)] pub(crate) enum Part { Percent, Text(String), diff --git a/crates/i18n/src/sprintf/mod.rs b/crates/i18n/src/sprintf/mod.rs index 355a3598..f9b1d8f1 100644 --- a/crates/i18n/src/sprintf/mod.rs +++ b/crates/i18n/src/sprintf/mod.rs @@ -12,6 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. +#![allow(unused_macros)] + mod argument; mod formatter; mod message; @@ -70,8 +72,12 @@ macro_rules! sprintf { }}; } +#[allow(unused_imports)] pub(crate) use arg_list; +#[allow(unused_imports)] pub(crate) use arg_list_inner; +#[allow(unused_imports)] +pub(crate) use sprintf; #[derive(Debug, Error)] #[error(transparent)] diff --git a/crates/i18n/src/translations.rs b/crates/i18n/src/translations.rs index d9277e2e..1dbdd180 100644 --- a/crates/i18n/src/translations.rs +++ b/crates/i18n/src/translations.rs @@ -14,41 +14,34 @@ use std::{collections::BTreeMap, ops::Deref}; +use icu_plurals::PluralCategory; use serde::{Deserialize, Serialize}; use crate::sprintf::Message; -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum PluralCategory { - Zero, - One, - Two, - Few, - Many, - Other, -} - -impl PluralCategory { - fn as_str(self) -> &'static str { - match self { - Self::Zero => "zero", - Self::One => "one", - Self::Two => "two", - Self::Few => "few", - Self::Many => "many", - Self::Other => "other", - } +fn plural_category_as_str(category: PluralCategory) -> &'static str { + match category { + PluralCategory::Zero => "zero", + PluralCategory::One => "one", + PluralCategory::Two => "two", + PluralCategory::Few => "few", + PluralCategory::Many => "many", + PluralCategory::Other => "other", } } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(untagged)] -enum TranslationTree { +pub enum TranslationTree { Message(Message), Children(BTreeMap), } impl TranslationTree { + /// Get a message from the tree by key. + /// + /// Returns `None` if the requested key is not found. + #[must_use] pub fn message(&self, key: &str) -> Option<&Message> { let keys = key.split('.'); let node = self.walk_path(keys)?; @@ -56,6 +49,12 @@ impl TranslationTree { Some(message) } + /// Get a pluralized message from the tree by key and plural category. + /// + /// If the key doesn't have plural variants, this will return the message + /// itself. Returns the "other" category if the requested category is + /// not found. Returns `None` if the requested key is not found. + #[must_use] pub fn pluralize(&self, key: &str, category: PluralCategory) -> Option<&Message> { let keys = key.split('.'); let node = self.walk_path(keys)?; @@ -65,7 +64,7 @@ impl TranslationTree { TranslationTree::Children(tree) => tree, }; - if let Some(node) = subtree.get(category.as_str()) { + if let Some(node) = subtree.get(plural_category_as_str(category)) { let message = node.as_message()?; Some(message) } else { diff --git a/crates/i18n/src/translator.rs b/crates/i18n/src/translator.rs new file mode 100644 index 00000000..8650d738 --- /dev/null +++ b/crates/i18n/src/translator.rs @@ -0,0 +1,423 @@ +// Copyright 2023 The Matrix.org Foundation C.I.C. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use std::{collections::HashMap, fs::File, str::FromStr}; + +use camino::{Utf8Path, Utf8PathBuf}; +use icu_list::{ListError, ListFormatter, ListLength}; +use icu_locid::{Locale, ParserError}; +use icu_locid_transform::fallback::LocaleFallbacker; +use icu_plurals::{PluralRules, PluralsError}; +use icu_provider::{ + data_key, fallback::LocaleFallbackConfig, DataError, DataErrorKind, DataKey, DataLocale, + DataRequest, DataRequestMetadata, +}; +use icu_provider_adapters::fallback::LocaleFallbackProvider; +use thiserror::Error; +use writeable::Writeable; + +use crate::{sprintf::Message, translations::TranslationTree}; + +/// Fake data key for errors +const DATA_KEY: DataKey = data_key!("mas/translations@1"); + +/// Error type for loading translations +#[derive(Debug, Error)] +#[error("Failed to load translations")] +pub enum LoadError { + Io(#[from] std::io::Error), + Deserialize(#[from] serde_json::Error), + InvalidLocale(#[from] ParserError), + InvalidFileName(Utf8PathBuf), +} + +/// A translator for a set of translations. +#[derive(Debug)] +pub struct Translator { + translations: HashMap, + plural_provider: LocaleFallbackProvider, + list_provider: LocaleFallbackProvider, + fallbacker: LocaleFallbacker, +} + +impl Translator { + /// Create a new translator from a set of translations. + #[must_use] + pub fn new(translations: HashMap) -> Self { + let fallbacker = LocaleFallbacker::new().static_to_owned(); + let plural_provider = LocaleFallbackProvider::new_with_fallbacker( + icu_plurals::provider::Baked, + fallbacker.clone(), + ); + let list_provider = LocaleFallbackProvider::new_with_fallbacker( + icu_list::provider::Baked, + fallbacker.clone(), + ); + + Self { + translations, + plural_provider, + list_provider, + fallbacker, + } + } + + /// Load a set of translations from a directory. + /// + /// The directory should contain one JSON file per locale, with the locale + /// being the filename without the extension, e.g. `en-US.json`. + /// + /// # Parameters + /// + /// * `path` - The path to load from. + /// + /// # Errors + /// + /// Returns an error if the directory cannot be read, or if any of the files + /// cannot be parsed. + pub fn load_from_path(path: &Utf8Path) -> Result { + let mut translations = HashMap::new(); + + let dir = path.read_dir_utf8()?; + for entry in dir { + let entry = entry?; + let path = entry.into_path(); + let Some(name) = path.file_stem() else { + return Err(LoadError::InvalidFileName(path)); + }; + + let locale: Locale = Locale::from_str(name)?; + + let mut file = File::open(path)?; + let content = serde_json::from_reader(&mut file)?; + translations.insert(locale.into(), content); + } + + Ok(Self::new(translations)) + } + + /// Get a message from the tree by key, with locale fallback. + /// + /// Returns the message and the locale it was found in. + /// If the message is not found, returns `None`. + /// + /// # Parameters + /// + /// * `locale` - The locale to use. + /// * `key` - The key to look up, which is a dot-separated path. + #[must_use] + pub fn message_with_fallback( + &self, + locale: DataLocale, + key: &str, + ) -> Option<(&Message, DataLocale)> { + let mut iter = self + .fallbacker + .for_config(LocaleFallbackConfig::default()) + .fallback_for(locale); + + loop { + let locale = iter.get(); + + if let Ok(message) = self.message(locale, key) { + return Some((message, iter.take())); + } + + // Stop if we hit the `und` locale + if locale.is_und() { + return None; + } + + iter.step(); + } + } + + /// Get a message from the tree by key. + /// + /// # Parameters + /// + /// * `locale` - The locale to use. + /// * `key` - The key to look up, which is a dot-separated path. + /// + /// # Errors + /// + /// Returns an error if the requested locale is not found, or if the + /// requested key is not found. + pub fn message(&self, locale: &DataLocale, key: &str) -> Result<&Message, DataError> { + let request = DataRequest { + locale, + metadata: DataRequestMetadata::default(), + }; + + let tree = self + .translations + .get(locale) + .ok_or(DataErrorKind::MissingLocale.with_req(DATA_KEY, request))?; + + let message = tree + .message(key) + .ok_or(DataErrorKind::MissingDataKey.with_req(DATA_KEY, request))?; + + Ok(message) + } + + /// Get a plural message from the tree by key, with locale fallback. + /// + /// Returns the message and the locale it was found in. + /// If the message is not found, returns `None`. + /// + /// # Parameters + /// + /// * `locale` - The locale to use. + /// * `key` - The key to look up, which is a dot-separated path. + /// * `count` - The count to use for pluralization. + #[must_use] + pub fn plural_with_fallback( + &self, + locale: DataLocale, + key: &str, + count: usize, + ) -> Option<(&Message, DataLocale)> { + let mut iter = self + .fallbacker + .for_config(LocaleFallbackConfig::default()) + .fallback_for(locale); + + loop { + let locale = iter.get(); + + if let Ok(message) = self.plural(locale, key, count) { + return Some((message, iter.take())); + } + + // Stop if we hit the `und` locale + if locale.is_und() { + return None; + } + + iter.step(); + } + } + + /// Get a plural message from the tree by key. + /// + /// # Parameters + /// + /// * `locale` - The locale to use. + /// * `key` - The key to look up, which is a dot-separated path. + /// * `count` - The count to use for pluralization. + /// + /// # Errors + /// + /// Returns an error if the requested locale is not found, or if the + /// requested key is not found. + pub fn plural( + &self, + locale: &DataLocale, + key: &str, + count: usize, + ) -> Result<&Message, PluralsError> { + let plurals = PluralRules::try_new_cardinal_unstable(&self.plural_provider, locale)?; + let category = plurals.category_for(count); + + let request = DataRequest { + locale, + metadata: DataRequestMetadata::default(), + }; + + let tree = self + .translations + .get(locale) + .ok_or(DataErrorKind::MissingLocale.with_req(DATA_KEY, request))?; + + let message = tree + .pluralize(key, category) + .ok_or(DataErrorKind::MissingDataKey.with_req(DATA_KEY, request))?; + + Ok(message) + } + + /// Format a list of items with the "and" conjunction. + /// + /// # Parameters + /// + /// * `locale` - The locale to use. + /// * `items` - The items to format. + /// + /// # Errors + /// + /// Returns an error if the requested locale is not found. + pub fn and_list<'a, W: Writeable + 'a, I: Iterator + Clone + 'a>( + &'a self, + locale: &DataLocale, + items: I, + ) -> Result { + let formatter = ListFormatter::try_new_and_with_length_unstable( + &self.list_provider, + locale, + ListLength::Wide, + )?; + + let list = formatter.format_to_string(items); + Ok(list) + } + + /// Format a list of items with the "or" conjunction. + /// + /// # Parameters + /// + /// * `locale` - The locale to use. + /// * `items` - The items to format. + /// + /// # Errors + /// + /// Returns an error if the requested locale is not found. + pub fn or_list<'a, W: Writeable + 'a, I: Iterator + Clone + 'a>( + &'a self, + locale: &DataLocale, + items: I, + ) -> Result { + let formatter = ListFormatter::try_new_or_with_length_unstable( + &self.list_provider, + locale, + ListLength::Wide, + )?; + + let list = formatter.format_to_string(items); + Ok(list) + } +} + +#[cfg(test)] +mod tests { + use camino::Utf8PathBuf; + use icu_locid::locale; + + use crate::{sprintf::arg_list, translator::Translator}; + + fn translator() -> Translator { + let root: Utf8PathBuf = env!("CARGO_MANIFEST_DIR").parse().unwrap(); + let test_data = root.join("test_data"); + Translator::load_from_path(&test_data).unwrap() + } + + #[test] + fn test_message() { + let translator = translator(); + + let message = translator.message(&locale!("en").into(), "hello").unwrap(); + let formatted = message.format(&arg_list!()).unwrap(); + assert_eq!(formatted, "Hello!"); + + let message = translator.message(&locale!("fr").into(), "hello").unwrap(); + let formatted = message.format(&arg_list!()).unwrap(); + assert_eq!(formatted, "Bonjour !"); + + let message = translator + .message(&locale!("en-US").into(), "hello") + .unwrap(); + let formatted = message.format(&arg_list!()).unwrap(); + assert_eq!(formatted, "Hey!"); + + // Try the fallback chain + let result = translator.message(&locale!("en-US").into(), "goodbye"); + assert!(result.is_err()); + + let (message, locale) = translator + .message_with_fallback(locale!("en-US").into(), "goodbye") + .unwrap(); + let formatted = message.format(&arg_list!()).unwrap(); + assert_eq!(formatted, "Goodbye!"); + assert_eq!(locale, locale!("en").into()); + } + + #[test] + fn test_plurals() { + let translator = translator(); + + let message = translator + .plural(&locale!("en").into(), "active_sessions", 1) + .unwrap(); + let formatted = message.format(&arg_list!(count = 1)).unwrap(); + assert_eq!(formatted, "1 active session."); + + let message = translator + .plural(&locale!("en").into(), "active_sessions", 2) + .unwrap(); + let formatted = message.format(&arg_list!(count = 2)).unwrap(); + assert_eq!(formatted, "2 active sessions."); + + // In english, zero is plural + let message = translator + .plural(&locale!("en").into(), "active_sessions", 0) + .unwrap(); + let formatted = message.format(&arg_list!(count = 0)).unwrap(); + assert_eq!(formatted, "0 active sessions."); + + let message = translator + .plural(&locale!("fr").into(), "active_sessions", 1) + .unwrap(); + let formatted = message.format(&arg_list!(count = 1)).unwrap(); + assert_eq!(formatted, "1 session active."); + + let message = translator + .plural(&locale!("fr").into(), "active_sessions", 2) + .unwrap(); + let formatted = message.format(&arg_list!(count = 2)).unwrap(); + assert_eq!(formatted, "2 sessions actives."); + + // In french, zero is singular + let message = translator + .plural(&locale!("fr").into(), "active_sessions", 0) + .unwrap(); + let formatted = message.format(&arg_list!(count = 0)).unwrap(); + assert_eq!(formatted, "0 session active."); + + // Try the fallback chain + let result = translator.plural(&locale!("en-US").into(), "active_sessions", 1); + assert!(result.is_err()); + + let (message, locale) = translator + .plural_with_fallback(locale!("en-US").into(), "active_sessions", 1) + .unwrap(); + let formatted = message.format(&arg_list!(count = 1)).unwrap(); + assert_eq!(formatted, "1 active session."); + assert_eq!(locale, locale!("en").into()); + } + + #[test] + fn test_list() { + let translator = translator(); + + let list = translator + .and_list(&locale!("en").into(), ["one", "two", "three"].iter()) + .unwrap(); + assert_eq!(list, "one, two, and three"); + + let list = translator + .and_list(&locale!("fr").into(), ["un", "deux", "trois"].iter()) + .unwrap(); + assert_eq!(list, "un, deux et trois"); + + let list = translator + .or_list(&locale!("en").into(), ["one", "two", "three"].iter()) + .unwrap(); + assert_eq!(list, "one, two, or three"); + + let list = translator + .or_list(&locale!("fr").into(), ["un", "deux", "trois"].iter()) + .unwrap(); + assert_eq!(list, "un, deux ou trois"); + } +} diff --git a/crates/i18n/test_data/en-US.json b/crates/i18n/test_data/en-US.json new file mode 100644 index 00000000..3d2f9beb --- /dev/null +++ b/crates/i18n/test_data/en-US.json @@ -0,0 +1,3 @@ +{ + "hello": "Hey!" +} \ No newline at end of file diff --git a/crates/i18n/test_data/en.json b/crates/i18n/test_data/en.json new file mode 100644 index 00000000..8734c2f3 --- /dev/null +++ b/crates/i18n/test_data/en.json @@ -0,0 +1,8 @@ +{ + "hello": "Hello!", + "goodbye": "Goodbye!", + "active_sessions": { + "one": "%(count)d active session.", + "other": "%(count)d active sessions." + } +} \ No newline at end of file diff --git a/crates/i18n/test_data/fr.json b/crates/i18n/test_data/fr.json new file mode 100644 index 00000000..24a982ac --- /dev/null +++ b/crates/i18n/test_data/fr.json @@ -0,0 +1,8 @@ +{ + "hello": "Bonjour !", + "goodbye": "Au revoir !", + "active_sessions": { + "one": "%(count)d session active.", + "other": "%(count)d sessions actives." + } +} \ No newline at end of file diff --git a/crates/iana-codegen/Cargo.toml b/crates/iana-codegen/Cargo.toml index 1fb85316..efb7b4a4 100644 --- a/crates/iana-codegen/Cargo.toml +++ b/crates/iana-codegen/Cargo.toml @@ -18,4 +18,4 @@ hyper = { version = "0.14.27", features = ["tcp", "client", "http1"] } serde.workspace = true tokio = { version = "1.32.0", features = ["macros", "rt-multi-thread", "fs", "io-util"] } tracing.workspace = true -tracing-subscriber = "0.3.17" +tracing-subscriber.workspace = true diff --git a/crates/listener/Cargo.toml b/crates/listener/Cargo.toml index 61517af6..e391b190 100644 --- a/crates/listener/Cargo.toml +++ b/crates/listener/Cargo.toml @@ -27,7 +27,7 @@ anyhow.workspace = true rustls-pemfile = "1.0.3" tokio = { version = "1.32.0", features = ["net", "rt", "macros", "signal", "time", "rt-multi-thread"] } tokio-test = "0.4.3" -tracing-subscriber = "0.3.17" +tracing-subscriber.workspace = true [[example]] name = "demo"