You've already forked authentication-service
mirror of
https://github.com/matrix-org/matrix-authentication-service.git
synced 2025-08-09 04:22:45 +03:00
i18n: translator structure, to hold translations
This commit is contained in:
255
Cargo.lock
generated
255
Cargo.lock
generated
@@ -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"
|
||||
|
@@ -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]
|
||||
|
@@ -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"] }
|
||||
|
@@ -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]
|
||||
|
@@ -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
|
||||
thiserror.workspace = true
|
||||
writeable = "0.5.3"
|
@@ -17,3 +17,9 @@
|
||||
|
||||
pub mod sprintf;
|
||||
pub mod translations;
|
||||
mod translator;
|
||||
|
||||
pub use self::{
|
||||
sprintf::{Argument, ArgumentList, Message},
|
||||
translator::Translator,
|
||||
};
|
||||
|
@@ -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<A: Into<Argument>> FromIterator<A> for List {
|
||||
}
|
||||
}
|
||||
|
||||
/// A single argument value.
|
||||
pub struct Argument {
|
||||
name: Option<String>,
|
||||
value: Value,
|
||||
|
@@ -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<ArgumentReference>,
|
||||
@@ -211,7 +211,7 @@ impl std::fmt::Display for Placeholder {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Message {
|
||||
parts: Vec<Part>,
|
||||
}
|
||||
@@ -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),
|
||||
|
@@ -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)]
|
||||
|
@@ -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<String, TranslationTree>),
|
||||
}
|
||||
|
||||
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 {
|
||||
|
423
crates/i18n/src/translator.rs
Normal file
423
crates/i18n/src/translator.rs
Normal file
@@ -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<DataLocale, TranslationTree>,
|
||||
plural_provider: LocaleFallbackProvider<icu_plurals::provider::Baked>,
|
||||
list_provider: LocaleFallbackProvider<icu_list::provider::Baked>,
|
||||
fallbacker: LocaleFallbacker,
|
||||
}
|
||||
|
||||
impl Translator {
|
||||
/// Create a new translator from a set of translations.
|
||||
#[must_use]
|
||||
pub fn new(translations: HashMap<DataLocale, TranslationTree>) -> 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<Self, LoadError> {
|
||||
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<Item = W> + Clone + 'a>(
|
||||
&'a self,
|
||||
locale: &DataLocale,
|
||||
items: I,
|
||||
) -> Result<String, ListError> {
|
||||
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<Item = W> + Clone + 'a>(
|
||||
&'a self,
|
||||
locale: &DataLocale,
|
||||
items: I,
|
||||
) -> Result<String, ListError> {
|
||||
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");
|
||||
}
|
||||
}
|
3
crates/i18n/test_data/en-US.json
Normal file
3
crates/i18n/test_data/en-US.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"hello": "Hey!"
|
||||
}
|
8
crates/i18n/test_data/en.json
Normal file
8
crates/i18n/test_data/en.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"hello": "Hello!",
|
||||
"goodbye": "Goodbye!",
|
||||
"active_sessions": {
|
||||
"one": "%(count)d active session.",
|
||||
"other": "%(count)d active sessions."
|
||||
}
|
||||
}
|
8
crates/i18n/test_data/fr.json
Normal file
8
crates/i18n/test_data/fr.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"hello": "Bonjour !",
|
||||
"goodbye": "Au revoir !",
|
||||
"active_sessions": {
|
||||
"one": "%(count)d session active.",
|
||||
"other": "%(count)d sessions actives."
|
||||
}
|
||||
}
|
@@ -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
|
||||
|
@@ -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"
|
||||
|
Reference in New Issue
Block a user