handlebars_fluent/
loader.rs

1use std::collections::HashMap;
2use std::fs::read_dir;
3use std::fs::File;
4use std::io;
5use std::io::prelude::*;
6use std::path::Path;
7
8use fluent_bundle::concurrent::FluentBundle;
9use fluent_bundle::{FluentArgs, FluentResource};
10use fluent_langneg::negotiate_languages;
11
12pub use unic_langid::{langid, langids, LanguageIdentifier};
13
14/// Something capable of looking up Fluent keys given a language.
15///
16/// Use [SimpleLoader] if you just need the basics
17pub trait Loader {
18    fn lookup(&self, lang: &LanguageIdentifier, text_id: &str, args: Option<&FluentArgs>)
19        -> String;
20}
21
22/// Loads Fluent data at runtime via `lazy_static` to produce a loader.
23///
24/// Usage:
25///
26/// ```rust
27/// use handlebars_fluent::*;
28///
29/// simple_loader!(create_loader, "./tests/locales/", "en-US");
30///
31/// fn init() {
32///     let loader = create_loader();
33///     let helper = FluentHelper::new(loader);
34/// }
35/// ```
36///
37/// `$constructor` is the name of the constructor function for the loader, `$location` is
38/// the location of a folder containing individual locale folders, `$fallback` is the language to use
39/// for fallback strings.
40///
41/// Some Fluent users have a share "core.ftl" file that contains strings used by all locales,
42/// for example branding information. They also may want to define custom functions on the bundle.
43///
44/// This can be done with an extended invocation:
45///
46/// ```rust
47/// use handlebars_fluent::*;
48///
49/// simple_loader!(create_loader, "./tests/locales/", "en-US", core: "./tests/core.ftl",
50///                customizer: |bundle| {bundle.add_function("FOOBAR", |_values, _named| {unimplemented!()}); });
51///
52/// fn init() {
53///     let loader = create_loader();
54///     let helper = FluentHelper::new(loader);
55/// }
56/// ```
57///
58/// The constructor function is cheap to call multiple times since all the heavy duty stuff is stored in shared statics.
59///
60#[macro_export]
61macro_rules! simple_loader {
62    ($constructor:ident, $location:expr, $fallback:expr) => {
63        $crate::lazy_static::lazy_static! {
64            static ref RESOURCES: std::collections::HashMap<$crate::loader::LanguageIdentifier, Vec<$crate::fluent_bundle::FluentResource>> = $crate::loader::build_resources($location);
65            static ref BUNDLES: std::collections::HashMap<$crate::loader::LanguageIdentifier, $crate::fluent_bundle::concurrent::FluentBundle<&'static $crate::fluent_bundle::FluentResource>> = $crate::loader::build_bundles(&&RESOURCES, None, |_bundle| {});
66            static ref LOCALES: Vec<$crate::loader::LanguageIdentifier> = RESOURCES.keys().cloned().collect();
67            static ref FALLBACKS: std::collections::HashMap<$crate::loader::LanguageIdentifier, Vec<$crate::loader::LanguageIdentifier>> = $crate::loader::build_fallbacks(&*LOCALES);
68        }
69
70        pub fn $constructor() -> $crate::loader::SimpleLoader {
71            $crate::loader::SimpleLoader::new(&*BUNDLES, &*FALLBACKS, $fallback.parse().expect("fallback language not valid"))
72        }
73    };
74    ($constructor:ident, $location:expr, $fallback:expr, core: $core:expr, customizer: $custom:expr) => {
75        $crate::lazy_static::lazy_static! {
76            static ref CORE_RESOURCE: $crate::fluent_bundle::FluentResource = $crate::loader::load_core_resource($core);
77            static ref RESOURCES: std::collections::HashMap<$crate::loader::LanguageIdentifier, Vec<$crate::fluent_bundle::FluentResource>> = $crate::loader::build_resources($location);
78            static ref BUNDLES: std::collections::HashMap<$crate::loader::LanguageIdentifier, $crate::fluent_bundle::concurrent::FluentBundle<&'static $crate::fluent_bundle::FluentResource>> = $crate::loader::build_bundles(&*RESOURCES, Some(&CORE_RESOURCE), $custom);
79            static ref LOCALES: Vec<$crate::loader::LanguageIdentifier> = RESOURCES.keys().cloned().collect();
80            static ref FALLBACKS: std::collections::HashMap<$crate::loader::LanguageIdentifier, Vec<$crate::loader::LanguageIdentifier>> = $crate::loader::build_fallbacks(&*LOCALES);
81        }
82
83        pub fn $constructor() -> $crate::loader::SimpleLoader {
84            $crate::loader::SimpleLoader::new(&*BUNDLES, &*FALLBACKS, $fallback.parse().expect("fallback language not valid"))
85        }
86    };
87}
88
89pub fn build_fallbacks(
90    locales: &[LanguageIdentifier],
91) -> HashMap<LanguageIdentifier, Vec<LanguageIdentifier>> {
92    let mut map = HashMap::new();
93
94    for locale in locales.iter() {
95        map.insert(
96            locale.to_owned(),
97            negotiate_languages(
98                &[locale],
99                locales,
100                None,
101                fluent_langneg::NegotiationStrategy::Filtering,
102            )
103            .into_iter()
104            .cloned()
105            .collect::<Vec<_>>(),
106        );
107    }
108
109    map
110}
111
112/// A simple Loader implementation, with statically-loaded fluent data.
113/// Typically created with the [`simple_loader!()`] macro
114pub struct SimpleLoader {
115    bundles: &'static HashMap<LanguageIdentifier, FluentBundle<&'static FluentResource>>,
116    fallbacks: &'static HashMap<LanguageIdentifier, Vec<LanguageIdentifier>>,
117    fallback: LanguageIdentifier,
118}
119
120impl SimpleLoader {
121    /// Construct a SimpleLoader
122    ///
123    /// You should probably be using the constructor from `simple_loader!()`
124    pub fn new(
125        bundles: &'static HashMap<LanguageIdentifier, FluentBundle<&'static FluentResource>>,
126        fallbacks: &'static HashMap<LanguageIdentifier, Vec<LanguageIdentifier>>,
127        fallback: LanguageIdentifier,
128    ) -> Self {
129        Self {
130            bundles,
131            fallbacks,
132            fallback,
133        }
134    }
135
136    /// Convenience function to look up a string for a single language
137    pub fn lookup_single_language(
138        &self,
139        lang: &LanguageIdentifier,
140        text_id: &str,
141        args: Option<&FluentArgs>,
142    ) -> Option<String> {
143        if let Some(bundle) = self.bundles.get(lang) {
144            if let Some(message) = bundle.get_message(text_id).and_then(|m| m.value()) {
145                let mut errors = Vec::new();
146
147                let value = bundle.format_pattern(message, args, &mut errors);
148
149                if errors.is_empty() {
150                    Some(value.into())
151                } else {
152                    panic!(
153                        "Failed to format a message for locale {} and id {}.\nErrors\n{:?}",
154                        lang, text_id, errors
155                    )
156                }
157            } else {
158                None
159            }
160        } else {
161            panic!("Unknown language {}", lang)
162        }
163    }
164
165    /// Convenience function to look up a string without falling back to the default fallback language
166    pub fn lookup_no_default_fallback(
167        &self,
168        lang: &LanguageIdentifier,
169        text_id: &str,
170        args: Option<&FluentArgs>,
171    ) -> Option<String> {
172        for l in self.fallbacks.get(lang).expect("language not found") {
173            if let Some(val) = self.lookup_single_language(l, text_id, args) {
174                return Some(val);
175            }
176        }
177
178        None
179    }
180}
181
182impl Loader for SimpleLoader {
183    // Traverse the fallback chain,
184    fn lookup(
185        &self,
186        lang: &LanguageIdentifier,
187        text_id: &str,
188        args: Option<&FluentArgs>,
189    ) -> String {
190        for l in self.fallbacks.get(lang).expect("language not found") {
191            if let Some(val) = self.lookup_single_language(l, text_id, args) {
192                return val;
193            }
194        }
195        if *lang != self.fallback {
196            if let Some(val) = self.lookup_single_language(&self.fallback, text_id, args) {
197                return val;
198            }
199        }
200        format!("Unknown localization {}", text_id)
201    }
202}
203
204fn read_from_file<P: AsRef<Path>>(filename: P) -> io::Result<FluentResource> {
205    let mut file = File::open(filename)?;
206    let mut string = String::new();
207
208    file.read_to_string(&mut string)?;
209
210    Ok(FluentResource::try_new(string).expect("File did not parse!"))
211}
212
213fn read_from_dir<P: AsRef<Path>>(dirname: P) -> io::Result<Vec<FluentResource>> {
214    let mut result = Vec::new();
215    for dir_entry in read_dir(dirname)? {
216        let entry = dir_entry?;
217
218        // Prevent loading non-FTL files as translations, such as VIM temporary files.
219        if entry.path().extension().and_then(|e| e.to_str()) != Some("ftl") {
220            continue;
221        }
222
223        let resource = read_from_file(entry.path())?;
224        result.push(resource);
225    }
226    Ok(result)
227}
228
229pub fn create_bundle(
230    lang: LanguageIdentifier,
231    resources: &'static [FluentResource],
232    core_resource: Option<&'static FluentResource>,
233    customizer: &impl Fn(&mut FluentBundle<&'static FluentResource>),
234) -> FluentBundle<&'static FluentResource> {
235    let mut bundle: FluentBundle<&'static FluentResource> =
236        FluentBundle::new_concurrent([lang].to_vec());
237
238    // handlebars variables may be used for URLs/etc as well
239    bundle.set_use_isolating(false);
240    if let Some(core) = core_resource {
241        bundle
242            .add_resource(core)
243            .expect("Failed to add core resource to bundle");
244    }
245    for res in resources {
246        bundle
247            .add_resource(res)
248            .expect("Failed to add FTL resources to the bundle.");
249    }
250
251    customizer(&mut bundle);
252    bundle
253}
254
255pub fn build_resources(dir: &str) -> HashMap<LanguageIdentifier, Vec<FluentResource>> {
256    let mut all_resources = HashMap::new();
257    let entries = read_dir(dir).unwrap();
258    for entry in entries {
259        let entry = entry.unwrap();
260        if entry.file_type().unwrap().is_dir() {
261            if let Ok(lang) = entry.file_name().into_string() {
262                let resources = read_from_dir(entry.path()).unwrap();
263                all_resources.insert(lang.parse().unwrap(), resources);
264            }
265        }
266    }
267    all_resources
268}
269
270pub fn build_bundles(
271    resources: &'static HashMap<LanguageIdentifier, Vec<FluentResource>>,
272    core_resource: Option<&'static FluentResource>,
273    customizer: impl Fn(&mut FluentBundle<&'static FluentResource>),
274) -> HashMap<LanguageIdentifier, FluentBundle<&'static FluentResource>> {
275    let mut bundles = HashMap::new();
276    for (k, v) in resources.iter() {
277        bundles.insert(
278            k.clone(),
279            create_bundle(k.clone(), v, core_resource, &customizer),
280        );
281    }
282    bundles
283}
284
285pub fn load_core_resource(path: &str) -> FluentResource {
286    read_from_file(path).expect("cannot find core resource")
287}
288
289#[cfg(test)]
290mod tests {
291    use super::*;
292    use fluent_bundle::concurrent::FluentBundle;
293    use std::error::Error;
294
295    #[test]
296    fn test_load_from_dir() -> Result<(), Box<dyn Error>> {
297        let dir = tempfile::tempdir()?;
298        std::fs::write(dir.path().join("core.ftl"), "foo = bar\n".as_bytes())?;
299        std::fs::write(dir.path().join("other.ftl"), "bar = baz\n".as_bytes())?;
300        std::fs::write(dir.path().join("invalid.txt"), "baz = foo\n".as_bytes())?;
301        std::fs::write(dir.path().join(".binary_file.swp"), [0, 1, 2, 3, 4, 5])?;
302
303        let result = read_from_dir(dir.path())?;
304        assert_eq!(2, result.len()); // Doesn't include the binary file or the txt file
305
306        let mut bundle = FluentBundle::new_concurrent((&[unic_langid::langid!("en-US")]).to_vec());
307        for resource in &result {
308            bundle.add_resource(resource).unwrap();
309        }
310
311        let mut errors = Vec::new();
312
313        // Ensure the correct files were loaded
314        assert_eq!(
315            "bar",
316            bundle.format_pattern(
317                bundle.get_message("foo").and_then(|m| m.value()).unwrap(),
318                None,
319                &mut errors
320            )
321        );
322
323        assert_eq!(
324            "baz",
325            bundle.format_pattern(
326                bundle.get_message("bar").and_then(|m| m.value()).unwrap(),
327                None,
328                &mut errors
329            )
330        );
331        assert_eq!(None, bundle.get_message("baz")); // The extension was txt
332
333        Ok(())
334    }
335}