2023-02-11 17:59:15 +00:00
|
|
|
/*
|
|
|
|
Copyright 2020-2023, Savanni D'Gerinel <savanni@luminescent-dreams.com>
|
|
|
|
|
|
|
|
This file is part of the Luminescent Dreams Tools.
|
|
|
|
|
|
|
|
Luminescent Dreams Tools is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
|
|
|
|
|
|
|
|
Luminescent Dreams Tools is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
|
|
|
|
|
|
|
|
You should have received a copy of the GNU General Public License along with Lumeto. If not, see <https://www.gnu.org/licenses/>.
|
|
|
|
*/
|
|
|
|
|
2021-12-15 00:48:22 +00:00
|
|
|
//! Provide a more ergonomic interface to the base Fluent library
|
|
|
|
//!
|
|
|
|
//! The Fluent class makes it easier to load translation bundles with language fallbacks and to go
|
|
|
|
//! through the most common steps of translating a message.
|
|
|
|
//!
|
2023-08-07 22:01:27 +00:00
|
|
|
use fluent_bundle::{bundle::FluentBundle, FluentArgs, FluentError, FluentResource};
|
2021-12-15 00:48:22 +00:00
|
|
|
use fluent_syntax::parser::ParserError;
|
|
|
|
use std::collections::hash_map::Entry;
|
|
|
|
use std::collections::HashMap;
|
|
|
|
use std::error;
|
|
|
|
use std::fmt;
|
|
|
|
use std::fs::File;
|
|
|
|
use std::io;
|
|
|
|
use std::io::Read;
|
|
|
|
use std::path::Path;
|
|
|
|
use std::string::FromUtf8Error;
|
|
|
|
use std::sync::{Arc, RwLock};
|
|
|
|
use unic_langid::LanguageIdentifier;
|
|
|
|
|
|
|
|
#[derive(Debug)]
|
|
|
|
pub enum Error {
|
|
|
|
/// All files must be UTF-8 encoded.
|
|
|
|
FileEncodingError(FromUtf8Error),
|
|
|
|
/// Fluent encountered an underlying error
|
|
|
|
FluentError(Vec<FluentError>),
|
|
|
|
/// Fluent encountered an underlying error while parsing the translation strings
|
|
|
|
FluentParserError(Vec<ParserError>),
|
|
|
|
/// There was an underlying IO error
|
|
|
|
IOError(io::Error),
|
|
|
|
/// No message could be found matching the specified message ID
|
|
|
|
NoMatchingMessage(String),
|
|
|
|
}
|
|
|
|
|
|
|
|
impl error::Error for Error {
|
|
|
|
fn source(&self) -> Option<&(dyn error::Error + 'static)> {
|
|
|
|
match self {
|
|
|
|
Error::FileEncodingError(error) => Some(error),
|
|
|
|
Error::NoMatchingMessage(_) => None,
|
|
|
|
Error::FluentParserError(_) => None,
|
|
|
|
Error::FluentError(_) => None,
|
|
|
|
Error::IOError(error) => Some(error),
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
impl fmt::Display for Error {
|
|
|
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
|
|
match self {
|
|
|
|
Error::FileEncodingError(error) => {
|
|
|
|
write!(f, "Translation file has an encoding problem: {}", error)
|
|
|
|
}
|
|
|
|
Error::FluentError(errs) => write!(f, "Fluent Error: {:?}", errs),
|
|
|
|
Error::FluentParserError(errs) => write!(f, "Fluent Parser Error: {:?}", errs),
|
|
|
|
Error::IOError(error) => write!(f, "IO Error: {}", error),
|
|
|
|
Error::NoMatchingMessage(id) => write!(f, "No matching message for {}", id),
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
impl From<(FluentResource, Vec<ParserError>)> for Error {
|
|
|
|
fn from(inp: (FluentResource, Vec<ParserError>)) -> Self {
|
|
|
|
let (_, error) = inp;
|
|
|
|
Error::FluentParserError(error)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
impl From<Vec<ParserError>> for Error {
|
|
|
|
fn from(error: Vec<ParserError>) -> Self {
|
|
|
|
Error::FluentParserError(error)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
impl From<Vec<FluentError>> for Error {
|
|
|
|
fn from(error: Vec<FluentError>) -> Self {
|
|
|
|
Error::FluentError(error)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
impl From<io::Error> for Error {
|
|
|
|
fn from(error: io::Error) -> Self {
|
|
|
|
Error::IOError(error)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
impl From<FromUtf8Error> for Error {
|
|
|
|
fn from(error: FromUtf8Error) -> Self {
|
|
|
|
Error::FileEncodingError(error)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
#[derive(Clone, Default)]
|
|
|
|
pub struct FluentErgo {
|
|
|
|
languages: Vec<LanguageIdentifier>,
|
2023-08-07 22:01:27 +00:00
|
|
|
bundles: Arc<
|
|
|
|
RwLock<
|
|
|
|
HashMap<
|
|
|
|
LanguageIdentifier,
|
|
|
|
FluentBundle<FluentResource, intl_memoizer::concurrent::IntlLangMemoizer>,
|
|
|
|
>,
|
|
|
|
>,
|
|
|
|
>,
|
2021-12-15 00:48:22 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
impl fmt::Debug for FluentErgo {
|
|
|
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
|
|
write!(f, "FluentErgo")
|
2022-04-21 13:14:53 +00:00
|
|
|
//write!(
|
|
|
|
//f,
|
|
|
|
//"FluentErgo {{ language: {:?}, units: {} }}",
|
|
|
|
//self.language, "whatever, for the moment"
|
|
|
|
//)
|
2021-12-15 00:48:22 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
/// An Ergonomic class wrapping the Fluent library
|
|
|
|
impl FluentErgo {
|
|
|
|
/// Construct the class with a list of languages. The list must be sorted in the order that
|
|
|
|
/// language packs will be tested. The first language listed will be the first language
|
|
|
|
/// searched for any translation message.
|
|
|
|
///
|
|
|
|
/// Typically, I call this as
|
|
|
|
///
|
|
|
|
/// ```
|
|
|
|
/// let eo_id = "eo".parse::<unic_langid::LanguageIdentifier>().unwrap();
|
|
|
|
/// let en_id = "en-US".parse::<unic_langid::LanguageIdentifier>().unwrap();
|
|
|
|
///
|
|
|
|
/// let mut fluent = fluent_ergonomics::FluentErgo::new(&[eo_id, en_id]);
|
|
|
|
/// ```
|
|
|
|
///
|
|
|
|
/// This specifies that I want to first look up messages in the Esperanto list, then fall back
|
|
|
|
/// to the English specfications if no Esperanto specification is present.
|
|
|
|
///
|
|
|
|
/// Note that no language resources are loaded during construction. You must call
|
|
|
|
/// `add_from_text` or `add_from_file` to load language packs.
|
|
|
|
pub fn new(languages: &[LanguageIdentifier]) -> FluentErgo {
|
|
|
|
FluentErgo {
|
|
|
|
languages: Vec::from(languages),
|
|
|
|
bundles: Arc::new(RwLock::new(HashMap::new())),
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
/// Add a list of translation strings from a string, which can be a constant hard-coded in the
|
|
|
|
/// application, loaded from a file, loaded from the internet, or wherever you like. `lang`
|
|
|
|
/// specifies which language the translation strings being provided.
|
|
|
|
///
|
|
|
|
/// You should not specify a language that you did not include in the constructor. You can, but
|
|
|
|
/// the translation function will only check those languages specified when this object was
|
|
|
|
/// constructed.
|
|
|
|
///
|
|
|
|
/// # Errors
|
|
|
|
///
|
|
|
|
/// * `FluentError`
|
|
|
|
/// * `FluentParserError`
|
|
|
|
///
|
|
|
|
pub fn add_from_text(&mut self, lang: LanguageIdentifier, text: String) -> Result<(), Error> {
|
|
|
|
let res = FluentResource::try_new(text)?;
|
|
|
|
let mut bundles = self.bundles.write().unwrap();
|
|
|
|
let entry = bundles.entry(lang.clone());
|
|
|
|
match entry {
|
|
|
|
Entry::Occupied(mut e) => {
|
|
|
|
let bundle = e.get_mut();
|
2023-10-04 20:25:29 +00:00
|
|
|
bundle.add_resource(res).map_err(Error::from)
|
2021-12-15 00:48:22 +00:00
|
|
|
}
|
|
|
|
Entry::Vacant(e) => {
|
2023-08-07 22:01:27 +00:00
|
|
|
let mut bundle: FluentBundle<
|
|
|
|
FluentResource,
|
|
|
|
intl_memoizer::concurrent::IntlLangMemoizer,
|
|
|
|
> = FluentBundle::new_concurrent(vec![lang]);
|
2023-10-04 20:25:29 +00:00
|
|
|
bundle.add_resource(res).map_err(Error::from)?;
|
2021-12-15 00:48:22 +00:00
|
|
|
e.insert(bundle);
|
|
|
|
Ok(())
|
|
|
|
}
|
|
|
|
}?;
|
|
|
|
Ok(())
|
|
|
|
}
|
|
|
|
|
|
|
|
/// Like `add_from_text`, but this will load the translation strings from a file.
|
|
|
|
///
|
|
|
|
/// Note that this will load the entire file into memory before passing it to Fluent. While I
|
|
|
|
/// think it is unlikely, it is possible that a translation file may be so big as to run the
|
|
|
|
/// computer out of memory.
|
|
|
|
///
|
|
|
|
/// # Errors
|
|
|
|
///
|
|
|
|
/// * `FluentError`
|
|
|
|
/// * `FluentParserError`
|
|
|
|
/// * `FileEncodingError` -- all files must be encoded in UTF-8. Most files saved from text
|
|
|
|
/// editors already do proper UTF-8 encoding, so this should rarely be a problem.
|
|
|
|
///
|
|
|
|
pub fn add_from_file(&mut self, lang: LanguageIdentifier, path: &Path) -> Result<(), Error> {
|
|
|
|
let mut v = Vec::new();
|
|
|
|
let mut f = File::open(path)?;
|
|
|
|
f.read_to_end(&mut v)?;
|
|
|
|
String::from_utf8(v)
|
|
|
|
.map_err(Error::FileEncodingError)
|
|
|
|
.and_then(|s| self.add_from_text(lang, s))
|
|
|
|
}
|
|
|
|
|
|
|
|
/// Run a translation.
|
|
|
|
///
|
|
|
|
/// `msgid` is the translation identifier as specified in the translation strings. `args` is a
|
|
|
|
/// set of Fluent arguments to be interpolated into the strings.
|
|
|
|
///
|
|
|
|
/// This function will search language bundles in the order that they were specified in the
|
|
|
|
/// constructor. NoMatchingMessage will be returned only if the message identifier cannot be
|
|
|
|
/// found in any bundle.
|
|
|
|
///
|
|
|
|
/// ```ignore
|
|
|
|
/// length-without-label = {$value}
|
|
|
|
/// swimming = Swimming
|
|
|
|
/// units = Units
|
|
|
|
/// ```
|
|
|
|
///
|
|
|
|
/// With this set of translation strings, `length-without-label`, `swimming`, and `units` are
|
|
|
|
/// all valid translation identifiers. See the documentation for `FluentBundle.get_message` for
|
|
|
|
/// more information.
|
|
|
|
///
|
|
|
|
/// A typical call with arguments would look like this:
|
|
|
|
///
|
|
|
|
/// ```
|
2023-08-07 22:01:27 +00:00
|
|
|
/// use fluent_bundle::{FluentArgs, FluentValue};
|
2021-12-15 00:48:22 +00:00
|
|
|
///
|
|
|
|
/// let eo_id = "eo".parse::<unic_langid::LanguageIdentifier>().unwrap();
|
|
|
|
/// let en_id = "en-US".parse::<unic_langid::LanguageIdentifier>().unwrap();
|
|
|
|
///
|
|
|
|
/// let mut fluent = fluent_ergonomics::FluentErgo::new(&[eo_id, en_id]);
|
|
|
|
/// let mut args = FluentArgs::new();
|
2023-08-07 22:01:27 +00:00
|
|
|
/// args.set("value", FluentValue::from("15"));
|
2021-12-15 00:48:22 +00:00
|
|
|
/// let r = fluent.tr("length-without-label", Some(&args));
|
|
|
|
/// ```
|
|
|
|
///
|
|
|
|
/// # Errors
|
|
|
|
///
|
|
|
|
/// * NoMatchingMessage -- this will be returned if the message identifier cannot be found in
|
|
|
|
/// any language bundle.
|
|
|
|
///
|
|
|
|
pub fn tr(&self, msgid: &str, args: Option<&FluentArgs>) -> Result<String, Error> {
|
|
|
|
let bundles = self.bundles.read().unwrap();
|
2023-10-04 20:25:29 +00:00
|
|
|
let result: Option<String> = self.languages.iter().find_map(|lang| {
|
|
|
|
let bundle = bundles.get(lang)?;
|
|
|
|
self.tr_(bundle, msgid, args)
|
|
|
|
});
|
2021-12-15 00:48:22 +00:00
|
|
|
|
|
|
|
match result {
|
|
|
|
Some(r) => Ok(r),
|
|
|
|
_ => Err(Error::NoMatchingMessage(String::from(msgid))),
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
fn tr_(
|
|
|
|
&self,
|
2023-08-07 22:01:27 +00:00
|
|
|
bundle: &FluentBundle<FluentResource, intl_memoizer::concurrent::IntlLangMemoizer>,
|
2021-12-15 00:48:22 +00:00
|
|
|
msgid: &str,
|
|
|
|
args: Option<&FluentArgs>,
|
2022-04-21 13:14:53 +00:00
|
|
|
) -> Option<String> {
|
2021-12-15 00:48:22 +00:00
|
|
|
let mut errors = vec![];
|
2022-04-21 13:14:53 +00:00
|
|
|
let pattern = bundle.get_message(msgid).and_then(|msg| msg.value());
|
2021-12-15 00:48:22 +00:00
|
|
|
let res = match pattern {
|
|
|
|
None => None,
|
|
|
|
Some(p) => {
|
2023-10-04 20:25:29 +00:00
|
|
|
let res = bundle.format_pattern(p, args, &mut errors);
|
|
|
|
if !errors.is_empty() {
|
2021-12-15 00:48:22 +00:00
|
|
|
println!("Errors in formatting: {:?}", errors)
|
|
|
|
}
|
|
|
|
|
|
|
|
Some(String::from(res))
|
|
|
|
}
|
|
|
|
};
|
|
|
|
match res {
|
|
|
|
Some(mut tr_string) => {
|
|
|
|
tr_string.retain(|v| v != '\u{2068}' && v != '\u{2069}');
|
|
|
|
Some(tr_string)
|
|
|
|
}
|
|
|
|
None => None,
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
mod tests {
|
|
|
|
use super::FluentErgo;
|
2023-08-07 22:01:27 +00:00
|
|
|
use fluent_bundle::{FluentArgs, FluentValue};
|
2021-12-15 00:48:22 +00:00
|
|
|
use unic_langid::LanguageIdentifier;
|
|
|
|
|
|
|
|
const EN_TRANSLATIONS: &'static str = "
|
|
|
|
preferences = Preferences
|
|
|
|
history = History
|
|
|
|
time_display = {$time} during the day
|
|
|
|
nested_display = nesting a time display: {time_display}
|
|
|
|
";
|
|
|
|
|
2022-04-21 13:14:53 +00:00
|
|
|
const EO_TRANSLATIONS: &'static str = "
|
2021-12-15 00:48:22 +00:00
|
|
|
history = Historio
|
|
|
|
";
|
|
|
|
|
2022-04-21 13:14:53 +00:00
|
|
|
#[test]
|
|
|
|
fn translations() {
|
|
|
|
let en_id = "en-US".parse::<LanguageIdentifier>().unwrap();
|
|
|
|
let mut fluent = FluentErgo::new(&vec![en_id.clone()]);
|
|
|
|
fluent
|
|
|
|
.add_from_text(en_id, String::from(EN_TRANSLATIONS))
|
|
|
|
.expect("text should load");
|
|
|
|
assert_eq!(
|
|
|
|
fluent.tr("preferences", None).unwrap(),
|
|
|
|
String::from("Preferences")
|
2021-12-15 00:48:22 +00:00
|
|
|
);
|
2022-04-21 13:14:53 +00:00
|
|
|
}
|
2021-12-15 00:48:22 +00:00
|
|
|
|
2022-04-21 13:14:53 +00:00
|
|
|
#[test]
|
|
|
|
fn translation_fallback() {
|
|
|
|
let eo_id = "eo".parse::<LanguageIdentifier>().unwrap();
|
|
|
|
let en_id = "en".parse::<LanguageIdentifier>().unwrap();
|
|
|
|
let mut fluent = FluentErgo::new(&vec![eo_id.clone(), en_id.clone()]);
|
|
|
|
fluent
|
|
|
|
.add_from_text(en_id, String::from(EN_TRANSLATIONS))
|
|
|
|
.expect("text should load");
|
|
|
|
fluent
|
|
|
|
.add_from_text(eo_id, String::from(EO_TRANSLATIONS))
|
|
|
|
.expect("text should load");
|
|
|
|
assert_eq!(
|
|
|
|
fluent.tr("preferences", None).unwrap(),
|
|
|
|
String::from("Preferences")
|
2021-12-15 00:48:22 +00:00
|
|
|
);
|
2022-04-21 13:14:53 +00:00
|
|
|
assert_eq!(
|
|
|
|
fluent.tr("history", None).unwrap(),
|
|
|
|
String::from("Historio")
|
2021-12-15 00:48:22 +00:00
|
|
|
);
|
2022-04-21 13:14:53 +00:00
|
|
|
}
|
2021-12-15 00:48:22 +00:00
|
|
|
|
2022-04-21 13:14:53 +00:00
|
|
|
#[test]
|
|
|
|
fn placeholder_insertion_should_strip_placeholder_markers() {
|
|
|
|
let en_id = "en".parse::<LanguageIdentifier>().unwrap();
|
|
|
|
let mut fluent = FluentErgo::new(&vec![en_id.clone()]);
|
|
|
|
fluent
|
|
|
|
.add_from_text(en_id, String::from(EN_TRANSLATIONS))
|
|
|
|
.expect("text should load");
|
|
|
|
let mut args = FluentArgs::new();
|
2022-12-31 17:39:11 +00:00
|
|
|
args.set("time", FluentValue::from(String::from("13:00")));
|
2022-04-21 13:14:53 +00:00
|
|
|
assert_eq!(
|
|
|
|
fluent.tr("time_display", Some(&args)).unwrap(),
|
|
|
|
String::from("13:00 during the day")
|
2021-12-15 00:48:22 +00:00
|
|
|
);
|
2022-04-21 13:14:53 +00:00
|
|
|
}
|
2021-12-15 00:48:22 +00:00
|
|
|
|
2022-04-21 13:14:53 +00:00
|
|
|
#[test]
|
|
|
|
fn placeholder_insertion_should_strip_nested_placeholder_markers() {
|
|
|
|
let en_id = "en".parse::<LanguageIdentifier>().unwrap();
|
|
|
|
let mut fluent = FluentErgo::new(&vec![en_id.clone()]);
|
|
|
|
fluent
|
|
|
|
.add_from_text(en_id, String::from(EN_TRANSLATIONS))
|
|
|
|
.expect("text should load");
|
|
|
|
let mut args = FluentArgs::new();
|
2022-12-31 17:39:11 +00:00
|
|
|
args.set("time", FluentValue::from(String::from("13:00")));
|
2022-04-21 13:14:53 +00:00
|
|
|
assert_eq!(
|
|
|
|
fluent.tr("nested_display", Some(&args)).unwrap(),
|
|
|
|
String::from("nesting a time display: 13:00 during the day")
|
2021-12-15 00:48:22 +00:00
|
|
|
);
|
2022-04-21 13:14:53 +00:00
|
|
|
}
|
2021-12-15 00:48:22 +00:00
|
|
|
|
2022-04-21 13:14:53 +00:00
|
|
|
#[test]
|
|
|
|
fn test_send() {
|
|
|
|
fn assert_send<T: Send>() {}
|
|
|
|
assert_send::<FluentErgo>();
|
|
|
|
}
|
2021-12-15 00:48:22 +00:00
|
|
|
|
2022-04-21 13:14:53 +00:00
|
|
|
#[test]
|
|
|
|
fn test_sync() {
|
|
|
|
fn assert_sync<T: Sync>() {}
|
|
|
|
assert_sync::<FluentErgo>();
|
|
|
|
}
|
2021-12-15 00:48:22 +00:00
|
|
|
}
|