Set up message generation using fluent-ergonomics
This commit is contained in:
parent
9699c63e50
commit
35c034b04b
|
@ -2718,10 +2718,12 @@ dependencies = [
|
||||||
"chrono-tz",
|
"chrono-tz",
|
||||||
"fixed_decimal",
|
"fixed_decimal",
|
||||||
"fluent",
|
"fluent",
|
||||||
|
"fluent-ergonomics",
|
||||||
"icu",
|
"icu",
|
||||||
"icu_locid",
|
"icu_locid",
|
||||||
"icu_provider",
|
"icu_provider",
|
||||||
"sys-locale",
|
"sys-locale",
|
||||||
|
"unic-langid",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
|
@ -0,0 +1,5 @@
|
||||||
|
welcome = Welcome
|
||||||
|
games-in-database = {count ->
|
||||||
|
[one] There is one game in the database
|
||||||
|
[other] There are {count} games in the database
|
||||||
|
}
|
|
@ -0,0 +1,5 @@
|
||||||
|
welcome = Bonvenon
|
||||||
|
games-in-database = {count ->
|
||||||
|
[one] Estas unu ludon en la datumbazo.
|
||||||
|
[other] Estas {count} ludojn en la datumbazo.
|
||||||
|
}
|
|
@ -1,21 +1,5 @@
|
||||||
use l10n::{FluentArgs, FluentValue, Message};
|
use l10n::{FluentArgs, FluentValue, Message};
|
||||||
|
|
||||||
const ENGLISH_MESSAGES: &'static str = "
|
|
||||||
welcome = Welcome
|
|
||||||
games-in-database = {count ->
|
|
||||||
[one] There is one game in the database
|
|
||||||
[other] There are {count} games in the database
|
|
||||||
}
|
|
||||||
";
|
|
||||||
|
|
||||||
const ESPERANTO_MESSAGES: &'static str = "
|
|
||||||
welcome = Bonvenon
|
|
||||||
games-in-database = {count ->
|
|
||||||
[one] Estas unu ludon en la datumbazo.
|
|
||||||
[other] Estas {count} ludojn en la datumbazo.
|
|
||||||
}
|
|
||||||
";
|
|
||||||
|
|
||||||
enum Phrase {
|
enum Phrase {
|
||||||
Welcome,
|
Welcome,
|
||||||
GamesInDatabase(i32),
|
GamesInDatabase(i32),
|
||||||
|
|
|
@ -10,7 +10,10 @@ chrono = { version = "0.4" }
|
||||||
chrono-tz = { version = "0.8" }
|
chrono-tz = { version = "0.8" }
|
||||||
fixed_decimal = { version = "0.5.5", features = [ "ryu" ] }
|
fixed_decimal = { version = "0.5.5", features = [ "ryu" ] }
|
||||||
fluent = { version = "0.16" }
|
fluent = { version = "0.16" }
|
||||||
|
fluent-ergonomics = { path = "../fluent-ergonomics" }
|
||||||
icu = { version = "1" }
|
icu = { version = "1" }
|
||||||
icu_locid = { version = "1" }
|
icu_locid = { version = "1" }
|
||||||
icu_provider = { version = "1" }
|
icu_provider = { version = "1" }
|
||||||
sys-locale = { version = "0.3" }
|
sys-locale = { version = "0.3" }
|
||||||
|
unic-langid = { version = "*" }
|
||||||
|
|
||||||
|
|
160
l10n/src/lib.rs
160
l10n/src/lib.rs
|
@ -1,19 +1,17 @@
|
||||||
use std::{ops::Deref, path::Path};
|
|
||||||
use chrono::{Datelike, NaiveDate, Timelike};
|
use chrono::{Datelike, NaiveDate, Timelike};
|
||||||
use chrono_tz::{Tz};
|
use chrono_tz::Tz;
|
||||||
use fixed_decimal::{FixedDecimal};
|
use fixed_decimal::FixedDecimal;
|
||||||
use icu::{
|
use fluent::{FluentBundle, FluentResource};
|
||||||
datetime::options::length,
|
use fluent_ergonomics::FluentErgo;
|
||||||
decimal::{FixedDecimalFormatter},
|
use icu::{datetime::options::length, decimal::FixedDecimalFormatter, locid::Locale};
|
||||||
locid::Locale,
|
|
||||||
};
|
|
||||||
use icu_provider::DataLocale;
|
use icu_provider::DataLocale;
|
||||||
|
use std::{collections::HashMap, ops::Deref, path::Path};
|
||||||
use sys_locale::get_locale;
|
use sys_locale::get_locale;
|
||||||
|
|
||||||
// Re-exports. I'm doing these so that clients of this library don't have to go tracking down
|
// Re-exports. I'm doing these so that clients of this library don't have to go tracking down
|
||||||
// additional structures
|
// additional structures
|
||||||
pub use fluent::{FluentValue, FluentArgs};
|
|
||||||
pub use fixed_decimal::FloatPrecision;
|
pub use fixed_decimal::FloatPrecision;
|
||||||
|
pub use fluent::{FluentArgs, FluentValue};
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub enum NonEmptyListError {
|
pub enum NonEmptyListError {
|
||||||
|
@ -22,12 +20,12 @@ pub enum NonEmptyListError {
|
||||||
|
|
||||||
pub struct NonEmptyList<A>(Vec<A>);
|
pub struct NonEmptyList<A>(Vec<A>);
|
||||||
|
|
||||||
impl <A> NonEmptyList<A> {
|
impl<A> NonEmptyList<A> {
|
||||||
fn new(elem: A) -> Self {
|
fn new(elem: A) -> Self {
|
||||||
Self(vec![elem])
|
Self(vec![elem])
|
||||||
}
|
}
|
||||||
|
|
||||||
fn from_iter(iter: impl IntoIterator<Item = A>) -> Result<NonEmptyList<A> ,NonEmptyListError> {
|
fn from_iter(iter: impl IntoIterator<Item = A>) -> Result<NonEmptyList<A>, NonEmptyListError> {
|
||||||
let lst = iter.into_iter().collect::<Vec<A>>();
|
let lst = iter.into_iter().collect::<Vec<A>>();
|
||||||
if lst.len() > 0 {
|
if lst.len() > 0 {
|
||||||
Ok(NonEmptyList(lst))
|
Ok(NonEmptyList(lst))
|
||||||
|
@ -39,7 +37,6 @@ impl <A> NonEmptyList<A> {
|
||||||
fn first(&self) -> &A {
|
fn first(&self) -> &A {
|
||||||
&self.0[0]
|
&self.0[0]
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<A> Deref for NonEmptyList<A> {
|
impl<A> Deref for NonEmptyList<A> {
|
||||||
|
@ -50,7 +47,7 @@ impl<A> Deref for NonEmptyList<A> {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub enum L10NError {
|
pub enum L10NError {
|
||||||
UnparsableLocale
|
UnparsableLocale,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<icu::locid::Error> for L10NError {
|
impl From<icu::locid::Error> for L10NError {
|
||||||
|
@ -68,28 +65,60 @@ impl From<icu::locid::Error> for L10NError {
|
||||||
// Nobody wants to generate all of that code, though I have done so in the past, and manually
|
// Nobody wants to generate all of that code, though I have done so in the past, and manually
|
||||||
// generating that code could be useful for illustration. I think I'm going to want to do code
|
// generating that code could be useful for illustration. I think I'm going to want to do code
|
||||||
// generation from the source strings file, and then compile the enumeration into the code.
|
// generation from the source strings file, and then compile the enumeration into the code.
|
||||||
|
// However, I have not found a mechanism in Fluent to identify all of the placeholders within a
|
||||||
|
// message, so I'm not even sure that I can automate this code generation.
|
||||||
pub trait Message {
|
pub trait Message {
|
||||||
fn msgid(&self) -> &str;
|
fn msgid(&self) -> &str;
|
||||||
fn args(&self) -> Option<FluentArgs>;
|
fn args(&self) -> Option<FluentArgs>;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct L10N {
|
pub struct L10N {
|
||||||
|
messages_root: std::path::PathBuf,
|
||||||
|
messages: FluentErgo,
|
||||||
|
|
||||||
locales: NonEmptyList<Locale>,
|
locales: NonEmptyList<Locale>,
|
||||||
zone: chrono_tz::Tz,
|
zone: chrono_tz::Tz,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for L10N {
|
impl L10N {
|
||||||
fn default() -> Self {
|
fn new(messages_root: std::path::PathBuf) -> Self {
|
||||||
let english = "en-US".parse::<Locale>().unwrap();
|
let english = "en-US".parse::<Locale>().unwrap();
|
||||||
let sys_locale = get_locale().and_then(|locale_str| locale_str.parse::<Locale>().ok()).unwrap_or(english);
|
let sys_locale = get_locale()
|
||||||
|
.and_then(|locale_str| locale_str.parse::<Locale>().ok())
|
||||||
|
.unwrap_or(english.clone());
|
||||||
let locales = NonEmptyList::new(sys_locale.clone());
|
let locales = NonEmptyList::new(sys_locale.clone());
|
||||||
let zone = chrono_tz::UTC;
|
let zone = chrono_tz::UTC;
|
||||||
Self { locales, zone }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl L10N {
|
/*
|
||||||
pub fn load_messages_from_file(&mut self, locale: String, path: &Path) -> Result<(), L10NError>{
|
let mut source_message_path = messages_root.clone();
|
||||||
|
source_message_path.push("en-US.ftl");
|
||||||
|
let english_phrases = FluentResource::try_new
|
||||||
|
*/
|
||||||
|
|
||||||
|
let messages = {
|
||||||
|
let mut english_messages = messages_root.clone();
|
||||||
|
english_messages.push("en-US.ftl");
|
||||||
|
|
||||||
|
let langid: unic_langid::LanguageIdentifier = english.to_string().parse().unwrap();
|
||||||
|
let mut messages = FluentErgo::new(&[langid.clone()]);
|
||||||
|
let _ = messages.add_from_file(langid, &english_messages);
|
||||||
|
|
||||||
|
messages
|
||||||
|
};
|
||||||
|
|
||||||
|
Self {
|
||||||
|
messages_root,
|
||||||
|
messages,
|
||||||
|
locales,
|
||||||
|
zone,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_messages_from_file(
|
||||||
|
&mut self,
|
||||||
|
locale: String,
|
||||||
|
path: &Path,
|
||||||
|
) -> Result<(), L10NError> {
|
||||||
unimplemented!()
|
unimplemented!()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -107,7 +136,10 @@ impl L10N {
|
||||||
// changed any time the list of locales gets changed. Also, the system can just run through the
|
// changed any time the list of locales gets changed. Also, the system can just run through the
|
||||||
// entire list of fallbacks.
|
// entire list of fallbacks.
|
||||||
pub fn set_locales(&mut self, locales: NonEmptyList<&str>) -> Result<(), L10NError> {
|
pub fn set_locales(&mut self, locales: NonEmptyList<&str>) -> Result<(), L10NError> {
|
||||||
let locales = locales.iter().map(|locale| Locale::try_from_bytes(locale.as_bytes())).collect::<Result<Vec<Locale>, icu::locid::Error>>()?;
|
let locales = locales
|
||||||
|
.iter()
|
||||||
|
.map(|locale| Locale::try_from_bytes(locale.as_bytes()))
|
||||||
|
.collect::<Result<Vec<Locale>, icu::locid::Error>>()?;
|
||||||
self.locales = NonEmptyList(locales);
|
self.locales = NonEmptyList(locales);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
@ -125,8 +157,8 @@ impl L10N {
|
||||||
// parameters. In an ideal world, neither of these can be incorrect. Messages are all checked
|
// parameters. In an ideal world, neither of these can be incorrect. Messages are all checked
|
||||||
// at compile time, as are their parameters. That implies an enumeration, with one element per
|
// at compile time, as are their parameters. That implies an enumeration, with one element per
|
||||||
// message, and with each element knowing its parameters.
|
// message, and with each element knowing its parameters.
|
||||||
pub fn format_message(&self) -> String {
|
pub fn messages(&self) -> FluentErgo {
|
||||||
unimplemented!()
|
self.messages.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn format_date_time_utc(
|
pub fn format_date_time_utc(
|
||||||
|
@ -225,8 +257,9 @@ impl L10N {
|
||||||
pub fn format_f64(&self, value: f64, precision: FloatPrecision) -> String {
|
pub fn format_f64(&self, value: f64, precision: FloatPrecision) -> String {
|
||||||
let fdf = FixedDecimalFormatter::try_new(
|
let fdf = FixedDecimalFormatter::try_new(
|
||||||
&self.locales.first().clone().into(),
|
&self.locales.first().clone().into(),
|
||||||
Default::default()
|
Default::default(),
|
||||||
).expect("locale should be present");
|
)
|
||||||
|
.expect("locale should be present");
|
||||||
|
|
||||||
let number = FixedDecimal::try_from_f64(value, precision).unwrap();
|
let number = FixedDecimal::try_from_f64(value, precision).unwrap();
|
||||||
|
|
||||||
|
@ -270,9 +303,10 @@ impl From<DateTime> for icu::calendar::DateTime<icu::calendar::Gregorian> {
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use fluent::fluent_args;
|
||||||
|
|
||||||
fn ref_l10n() -> L10N {
|
fn ref_l10n() -> L10N {
|
||||||
let mut l10n = L10N::default();
|
let mut l10n = L10N::new(std::path::PathBuf::from("./test_files"));
|
||||||
// Make sure we know the locale before the test begins. Some systems, such as my own, are
|
// Make sure we know the locale before the test begins. Some systems, such as my own, are
|
||||||
// not actually in English.
|
// not actually in English.
|
||||||
l10n.set_locales(NonEmptyList::from_iter(vec!["en-US"]).unwrap());
|
l10n.set_locales(NonEmptyList::from_iter(vec!["en-US"]).unwrap());
|
||||||
|
@ -353,23 +387,79 @@ mod tests {
|
||||||
fn it_formats_a_number_according_to_locale() {
|
fn it_formats_a_number_according_to_locale() {
|
||||||
let mut l10n = ref_l10n();
|
let mut l10n = ref_l10n();
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(l10n.format_f64(100.4, FloatPrecision::Floating), "100.4",);
|
||||||
l10n.format_f64(100.4, FloatPrecision::Floating),
|
|
||||||
"100.4",
|
|
||||||
);
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
l10n.format_f64(15000.4, FloatPrecision::Floating),
|
l10n.format_f64(15000.4, FloatPrecision::Floating),
|
||||||
"15,000.4",
|
"15,000.4",
|
||||||
);
|
);
|
||||||
|
|
||||||
l10n.set_locales(NonEmptyList::from_iter(vec!["de-DE", "en-US"]).unwrap());
|
l10n.set_locales(NonEmptyList::from_iter(vec!["de-DE", "en-US"]).unwrap());
|
||||||
assert_eq!(
|
assert_eq!(l10n.format_f64(100.4, FloatPrecision::Floating), "100,4",);
|
||||||
l10n.format_f64(100.4, FloatPrecision::Floating),
|
|
||||||
"100,4",
|
|
||||||
);
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
l10n.format_f64(15000.4, FloatPrecision::Floating),
|
l10n.format_f64(15000.4, FloatPrecision::Floating),
|
||||||
"15.000,4",
|
"15.000,4",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn it_can_load_message_files() {
|
||||||
|
let mut l10n = ref_l10n();
|
||||||
|
let messages = l10n.messages();
|
||||||
|
|
||||||
|
let args = fluent_args![
|
||||||
|
"name" => "Savanni"
|
||||||
|
];
|
||||||
|
assert_eq!(
|
||||||
|
messages.tr("welcome", Some(&args)).unwrap(),
|
||||||
|
"Hello, Savanni"
|
||||||
|
);
|
||||||
|
|
||||||
|
let args = fluent_args![
|
||||||
|
"count" => 1
|
||||||
|
];
|
||||||
|
assert_eq!(
|
||||||
|
messages.tr("games-in-database", Some(&args)).unwrap(),
|
||||||
|
"There is one game in the database"
|
||||||
|
);
|
||||||
|
|
||||||
|
let args = fluent_args![
|
||||||
|
"count" => 2
|
||||||
|
];
|
||||||
|
assert_eq!(
|
||||||
|
messages.tr("games-in-database", Some(&args)).unwrap(),
|
||||||
|
"There are 2 games in the database"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
#[test]
|
||||||
|
fn it_can_change_languages_on_locale_change() {
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn phrases_can_be_translated() {
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn phrases_can_fall_back() {
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* Not really a unit test, more of a test to see what I could introspect within a fluent
|
||||||
|
* message. I was hoping that attributes would give me placeholder names, but that doesn't seem
|
||||||
|
* to be the case.
|
||||||
|
#[test]
|
||||||
|
fn messages() {
|
||||||
|
let langid_en = "en-US".parse().expect("Parsing failed.");
|
||||||
|
let resource = FluentResource::try_new(MESSAGES.to_owned()).unwrap();
|
||||||
|
let mut bundle = FluentBundle::new(vec![langid_en]);
|
||||||
|
bundle.add_resource(&resource).unwrap();
|
||||||
|
|
||||||
|
let msg = bundle.get_message("welcome").expect("message should exist");
|
||||||
|
for attr in msg.attributes() {
|
||||||
|
println!("attr: {:?}", attr);
|
||||||
|
}
|
||||||
|
assert!(false);
|
||||||
|
}
|
||||||
|
*/
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,5 @@
|
||||||
|
welcome = Hello, {$name}
|
||||||
|
games-in-database = {$count ->
|
||||||
|
[one] There is one game in the database
|
||||||
|
*[other] There are {$count} games in the database
|
||||||
|
}
|
Loading…
Reference in New Issue