Skip to main content

fluent_ergonomics/
lib.rs

1//! Provide a more ergonomic interface to the base Fluent library
2//!
3//! The Fluent class makes it easier to load translation bundles with language fallbacks and to go
4//! through the most common steps of translating a message.
5//!
6use fluent::concurrent::FluentBundle;
7use fluent::{FluentArgs, FluentError, FluentResource};
8use fluent_syntax::parser::ParserError;
9use std::collections::hash_map::Entry;
10use std::collections::HashMap;
11use std::error;
12use std::fmt;
13use std::fs::File;
14use std::io;
15use std::io::Read;
16use std::path::Path;
17use std::string::FromUtf8Error;
18use std::sync::{Arc, RwLock};
19use unic_langid::LanguageIdentifier;
20
21#[derive(Debug)]
22pub enum Error {
23    /// All files must be UTF-8 encoded.
24    FileEncodingError(FromUtf8Error),
25    /// Fluent encountered an underlying error
26    FluentError(Vec<FluentError>),
27    /// Fluent encountered an underlying error while parsing the translation strings
28    FluentParserError(Vec<ParserError>),
29    /// There was an underlying IO error
30    IOError(io::Error),
31    /// No message could be found matching the specified message ID
32    NoMatchingMessage(String),
33}
34
35impl error::Error for Error {
36    fn source(&self) -> Option<&(dyn error::Error + 'static)> {
37        match self {
38            Error::FileEncodingError(error) => Some(error),
39            Error::NoMatchingMessage(_) => None,
40            Error::FluentParserError(_) => None,
41            Error::FluentError(_) => None,
42            Error::IOError(error) => Some(error),
43        }
44    }
45}
46
47impl fmt::Display for Error {
48    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
49        match self {
50            Error::FileEncodingError(error) => {
51                write!(f, "Translation file has an encoding problem: {}", error)
52            }
53            Error::FluentError(errs) => write!(f, "Fluent Error: {:?}", errs),
54            Error::FluentParserError(errs) => write!(f, "Fluent Parser Error: {:?}", errs),
55            Error::IOError(error) => write!(f, "IO Error: {}", error),
56            Error::NoMatchingMessage(id) => write!(f, "No matching message for {}", id),
57        }
58    }
59}
60
61impl From<(FluentResource, Vec<ParserError>)> for Error {
62    fn from(inp: (FluentResource, Vec<ParserError>)) -> Self {
63        let (_, error) = inp;
64        Error::FluentParserError(error)
65    }
66}
67
68impl From<Vec<ParserError>> for Error {
69    fn from(error: Vec<ParserError>) -> Self {
70        Error::FluentParserError(error)
71    }
72}
73
74impl From<Vec<FluentError>> for Error {
75    fn from(error: Vec<FluentError>) -> Self {
76        Error::FluentError(error)
77    }
78}
79
80impl From<io::Error> for Error {
81    fn from(error: io::Error) -> Self {
82        Error::IOError(error)
83    }
84}
85
86impl From<FromUtf8Error> for Error {
87    fn from(error: FromUtf8Error) -> Self {
88        Error::FileEncodingError(error)
89    }
90}
91
92#[derive(Clone, Default)]
93pub struct FluentErgo {
94    languages: Vec<LanguageIdentifier>,
95    bundles: Arc<RwLock<HashMap<LanguageIdentifier, FluentBundle<FluentResource>>>>,
96}
97
98impl fmt::Debug for FluentErgo {
99    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
100        write!(f, "FluentErgo")
101        //write!(
102        //f,
103        //"FluentErgo {{ language: {:?}, units: {} }}",
104        //self.language, "whatever, for the moment"
105        //)
106    }
107}
108
109/// An Ergonomic class wrapping the Fluent library
110impl FluentErgo {
111    /// Construct the class with a list of languages. The list must be sorted in the order that
112    /// language packs will be tested. The first language listed will be the first language
113    /// searched for any translation message.
114    ///
115    /// Typically, I call this as
116    ///
117    /// ```
118    /// let eo_id = "eo".parse::<unic_langid::LanguageIdentifier>().unwrap();
119    /// let en_id = "en-US".parse::<unic_langid::LanguageIdentifier>().unwrap();
120    ///
121    /// let mut fluent = fluent_ergonomics::FluentErgo::new(&[eo_id, en_id]);
122    /// ```
123    ///
124    /// This specifies that I want to first look up messages in the Esperanto list, then fall back
125    /// to the English specfications if no Esperanto specification is present.
126    ///
127    /// Note that no language resources are loaded during construction. You must call
128    /// `add_from_text` or `add_from_file` to load language packs.
129    pub fn new(languages: &[LanguageIdentifier]) -> FluentErgo {
130        FluentErgo {
131            languages: Vec::from(languages),
132            bundles: Arc::new(RwLock::new(HashMap::new())),
133        }
134    }
135
136    /// Add a list of translation strings from a string, which can be a constant hard-coded in the
137    /// application, loaded from a file, loaded from the internet, or wherever you like. `lang`
138    /// specifies which language the translation strings being provided.
139    ///
140    /// You should not specify a language that you did not include in the constructor. You can, but
141    /// the translation function will only check those languages specified when this object was
142    /// constructed.
143    ///
144    /// # Errors
145    ///
146    /// * `FluentError`
147    /// * `FluentParserError`
148    ///
149    pub fn add_from_text(&mut self, lang: LanguageIdentifier, text: String) -> Result<(), Error> {
150        let res = FluentResource::try_new(text)?;
151        let mut bundles = self.bundles.write().unwrap();
152        let entry = bundles.entry(lang.clone());
153        match entry {
154            Entry::Occupied(mut e) => {
155                let bundle = e.get_mut();
156                bundle.add_resource(res).map_err(|err| Error::from(err))
157            }
158            Entry::Vacant(e) => {
159                let mut bundle = FluentBundle::new(&[lang]);
160                bundle.add_resource(res).map_err(|err| Error::from(err))?;
161                e.insert(bundle);
162                Ok(())
163            }
164        }?;
165        Ok(())
166    }
167
168    /// Like `add_from_text`, but this will load the translation strings from a file.
169    ///
170    /// Note that this will load the entire file into memory before passing it to Fluent. While I
171    /// think it is unlikely, it is possible that a translation file may be so big as to run the
172    /// computer out of memory.
173    ///
174    /// # Errors
175    ///
176    /// * `FluentError`
177    /// * `FluentParserError`
178    /// * `FileEncodingError` -- all files must be encoded in UTF-8. Most files saved from text
179    /// editors already do proper UTF-8 encoding, so this should rarely be a problem.
180    ///
181    pub fn add_from_file(&mut self, lang: LanguageIdentifier, path: &Path) -> Result<(), Error> {
182        let mut v = Vec::new();
183        let mut f = File::open(path)?;
184        f.read_to_end(&mut v)?;
185        String::from_utf8(v)
186            .map_err(Error::FileEncodingError)
187            .and_then(|s| self.add_from_text(lang, s))
188    }
189
190    /// Run a translation.
191    ///
192    /// `msgid` is the translation identifier as specified in the translation strings. `args` is a
193    /// set of Fluent arguments to be interpolated into the strings.
194    ///
195    /// This function will search language bundles in the order that they were specified in the
196    /// constructor. NoMatchingMessage will be returned only if the message identifier cannot be
197    /// found in any bundle.
198    ///
199    /// ```ignore
200    /// length-without-label = {$value}
201    /// swimming = Swimming
202    /// units = Units
203    /// ```
204    ///
205    /// With this set of translation strings, `length-without-label`, `swimming`, and `units` are
206    /// all valid translation identifiers. See the documentation for `FluentBundle.get_message` for
207    /// more information.
208    ///
209    /// A typical call with arguments would look like this:
210    ///
211    /// ```
212    /// use fluent::{FluentArgs, FluentValue};
213    ///
214    /// let eo_id = "eo".parse::<unic_langid::LanguageIdentifier>().unwrap();
215    /// let en_id = "en-US".parse::<unic_langid::LanguageIdentifier>().unwrap();
216    ///
217    /// let mut fluent = fluent_ergonomics::FluentErgo::new(&[eo_id, en_id]);
218    /// let mut args = FluentArgs::new();
219    /// args.insert("value", FluentValue::from("15"));
220    /// let r = fluent.tr("length-without-label", Some(&args));
221    /// ```
222    ///
223    /// # Errors
224    ///
225    /// * NoMatchingMessage -- this will be returned if the message identifier cannot be found in
226    /// any language bundle.
227    ///
228    pub fn tr(&self, msgid: &str, args: Option<&FluentArgs>) -> Result<String, Error> {
229        let bundles = self.bundles.read().unwrap();
230        let result: Option<String> = self
231            .languages
232            .iter()
233            .map(|lang| {
234                let bundle = bundles.get(lang)?;
235                self.tr_(bundle, msgid, args)
236            })
237            .filter(|v| v.is_some())
238            .map(|v| v.unwrap())
239            .next();
240
241        match result {
242            Some(r) => Ok(r),
243            _ => Err(Error::NoMatchingMessage(String::from(msgid))),
244        }
245    }
246
247    fn tr_(
248        &self,
249        bundle: &FluentBundle<FluentResource>,
250        msgid: &str,
251        args: Option<&FluentArgs>,
252    ) -> Option<String> {
253        let mut errors = vec![];
254        let pattern = bundle.get_message(msgid).and_then(|msg| msg.value);
255        let res = match pattern {
256            None => None,
257            Some(p) => {
258                let res = bundle.format_pattern(&p, args, &mut errors);
259                if errors.len() > 0 {
260                    println!("Errors in formatting: {:?}", errors)
261                }
262
263                Some(String::from(res))
264            }
265        };
266        match res {
267            Some(mut tr_string) => {
268                tr_string.retain(|v| v != '\u{2068}' && v != '\u{2069}');
269                Some(tr_string)
270            }
271            None => None,
272        }
273    }
274}
275
276#[cfg(test)]
277mod tests {
278    use super::FluentErgo;
279    use fluent::{FluentArgs, FluentValue};
280    use unic_langid::LanguageIdentifier;
281
282    const EN_TRANSLATIONS: &'static str = "
283preferences = Preferences
284history = History
285time_display = {$time} during the day
286nested_display = nesting a time display: {time_display}
287";
288
289    const EO_TRANSLATIONS: &'static str = "
290history = Historio
291";
292
293    #[test]
294    fn translations() {
295        let en_id = "en-US".parse::<LanguageIdentifier>().unwrap();
296        let mut fluent = FluentErgo::new(&vec![en_id.clone()]);
297        fluent
298            .add_from_text(en_id, String::from(EN_TRANSLATIONS))
299            .expect("text should load");
300        assert_eq!(
301            fluent.tr("preferences", None).unwrap(),
302            String::from("Preferences")
303        );
304    }
305
306    #[test]
307    fn translation_fallback() {
308        let eo_id = "eo".parse::<LanguageIdentifier>().unwrap();
309        let en_id = "en".parse::<LanguageIdentifier>().unwrap();
310        let mut fluent = FluentErgo::new(&vec![eo_id.clone(), en_id.clone()]);
311        fluent
312            .add_from_text(en_id, String::from(EN_TRANSLATIONS))
313            .expect("text should load");
314        fluent
315            .add_from_text(eo_id, String::from(EO_TRANSLATIONS))
316            .expect("text should load");
317        assert_eq!(
318            fluent.tr("preferences", None).unwrap(),
319            String::from("Preferences")
320        );
321        assert_eq!(
322            fluent.tr("history", None).unwrap(),
323            String::from("Historio")
324        );
325    }
326
327    #[test]
328    fn placeholder_insertion_should_strip_placeholder_markers() {
329        let en_id = "en".parse::<LanguageIdentifier>().unwrap();
330        let mut fluent = FluentErgo::new(&vec![en_id.clone()]);
331        fluent
332            .add_from_text(en_id, String::from(EN_TRANSLATIONS))
333            .expect("text should load");
334        let mut args = FluentArgs::new();
335        args.insert("time", FluentValue::from(String::from("13:00")));
336        assert_eq!(
337            fluent.tr("time_display", Some(&args)).unwrap(),
338            String::from("13:00 during the day")
339        );
340    }
341
342    #[test]
343    fn placeholder_insertion_should_strip_nested_placeholder_markers() {
344        let en_id = "en".parse::<LanguageIdentifier>().unwrap();
345        let mut fluent = FluentErgo::new(&vec![en_id.clone()]);
346        fluent
347            .add_from_text(en_id, String::from(EN_TRANSLATIONS))
348            .expect("text should load");
349        let mut args = FluentArgs::new();
350        args.insert("time", FluentValue::from(String::from("13:00")));
351        assert_eq!(
352            fluent.tr("nested_display", Some(&args)).unwrap(),
353            String::from("nesting a time display: 13:00 during the day")
354        );
355    }
356
357    #[test]
358    fn test_send() {
359        fn assert_send<T: Send>() {}
360        assert_send::<FluentErgo>();
361    }
362
363    #[test]
364    fn test_sync() {
365        fn assert_sync<T: Sync>() {}
366        assert_sync::<FluentErgo>();
367    }
368}