Set up message generation using fluent-ergonomics

This commit is contained in:
Savanni D'Gerinel 2024-03-05 08:50:54 -05:00
parent 9699c63e50
commit 35c034b04b
7 changed files with 147 additions and 53 deletions

2
Cargo.lock generated
View File

@ -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]]

View File

@ -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
}

View File

@ -0,0 +1,5 @@
welcome = Bonvenon
games-in-database = {count ->
[one] Estas unu ludon en la datumbazo.
[other] Estas {count} ludojn en la datumbazo.
}

View File

@ -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),

View File

@ -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 = "*" }

View File

@ -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 {
@ -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 }
/*
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,
} }
} }
impl L10N { pub fn load_messages_from_file(
pub fn load_messages_from_file(&mut self, locale: String, path: &Path) -> Result<(), L10NError>{ &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);
}
*/
} }

View File

@ -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
}