fluent_resmgr/
resource_manager.rs

1use elsa::FrozenMap;
2use fluent_bundle::{FluentBundle, FluentResource};
3use fluent_fallback::{
4    generator::{BundleGenerator, FluentBundleResult},
5    types::ResourceId,
6};
7use futures::stream::Stream;
8use rustc_hash::FxHashSet;
9use std::io;
10use std::{fs, iter};
11use thiserror::Error;
12use unic_langid::LanguageIdentifier;
13
14fn read_file(path: &str) -> Result<String, io::Error> {
15    fs::read_to_string(path)
16}
17
18/// [`ResourceManager`] provides a standalone solution for managing localization resources which
19/// can be used by `fluent-fallback` or other higher level bindings.
20pub struct ResourceManager {
21    resources: FrozenMap<String, Box<FluentResource>>,
22    path_scheme: String,
23}
24
25impl ResourceManager {
26    /// Create a new and empty [`ResourceManager`]. As resources are added they will be
27    /// retained in the `resources` [`FrozenMap`]. The `path_scheme` argument defines
28    /// how the files are organized.
29    ///
30    /// For instance `"./translations/{locale}/{res_id}"` will load files with the
31    /// following structure:
32    ///
33    /// .
34    /// └── translations
35    ///     ├── en-US
36    ///     │   ├── app.ftl
37    ///     │   └── errors.ftl
38    ///     └── pl
39    ///         ├── app.ftl
40    ///         └── errors.ftl
41    ///
42    pub fn new(path_scheme: String) -> Self {
43        ResourceManager {
44            resources: FrozenMap::new(),
45            path_scheme,
46        }
47    }
48
49    /// Returns a [`FluentResource`], by either reading the file and loading it into
50    /// memory, or retrieving it from an in-memory cache.
51    fn get_resource(
52        &self,
53        resource_id: &str,
54        locale: &str,
55    ) -> Result<&FluentResource, ResourceManagerError> {
56        let path = self
57            .path_scheme
58            .replace("{locale}", locale)
59            .replace("{res_id}", resource_id);
60        Ok(if let Some(resource) = self.resources.get(&path) {
61            resource
62        } else {
63            let resource = match FluentResource::try_new(read_file(&path)?) {
64                Ok(resource) => resource,
65                Err((resource, _err)) => resource,
66            };
67            self.resources.insert(path.to_string(), Box::new(resource))
68        })
69    }
70
71    /// Gets a [`FluentBundle`] from a list of resources. The bundle will only contain the
72    /// resources from the first locale in the locales list. The other locales will be
73    /// stored in the [`FluentBundle`] and will only be used for custom formatters such
74    /// date time format, or plural rules. The message formatting will not fall back
75    /// to other locales.
76    pub fn get_bundle(
77        &self,
78        locales: Vec<LanguageIdentifier>,
79        resource_ids: Vec<String>,
80    ) -> Result<FluentBundle<&FluentResource>, Vec<ResourceManagerError>> {
81        let mut errors: Vec<ResourceManagerError> = vec![];
82        let mut bundle = FluentBundle::new(locales.clone());
83        let locale = &locales[0];
84
85        for resource_id in &resource_ids {
86            match self.get_resource(resource_id, &locale.to_string()) {
87                Ok(resource) => {
88                    if let Err(errs) = bundle.add_resource(resource) {
89                        for error in errs {
90                            errors.push(ResourceManagerError::Fluent(error));
91                        }
92                    }
93                }
94                Err(error) => errors.push(error),
95            };
96        }
97
98        if errors.is_empty() {
99            Ok(bundle)
100        } else {
101            Err(errors)
102        }
103    }
104
105    /// Returns an iterator for a [`FluentBundle`] for each locale provided. Each
106    /// iteration will load all of the resources for that single locale. i18n formatters
107    /// such as date time format and plural rules will ignore the list of locales,
108    /// unlike `get_bundle` and only use the single locale of the bundle.
109    pub fn get_bundles(
110        &self,
111        locales: Vec<LanguageIdentifier>,
112        resource_ids: Vec<String>,
113    ) -> impl Iterator<Item = Result<FluentBundle<&FluentResource>, Vec<ResourceManagerError>>>
114    {
115        let mut idx = 0;
116
117        iter::from_fn(move || {
118            locales.get(idx).map(|locale| {
119                idx += 1;
120                let mut errors: Vec<ResourceManagerError> = vec![];
121                let mut bundle = FluentBundle::new(vec![locale.clone()]);
122
123                for resource_id in &resource_ids {
124                    match self.get_resource(resource_id, &locale.to_string()) {
125                        Ok(resource) => {
126                            if let Err(errs) = bundle.add_resource(resource) {
127                                for error in errs {
128                                    errors.push(ResourceManagerError::Fluent(error));
129                                }
130                            }
131                        }
132                        Err(error) => errors.push(error),
133                    }
134                }
135
136                if !errors.is_empty() {
137                    Err(errors)
138                } else {
139                    Ok(bundle)
140                }
141            })
142        })
143    }
144}
145
146/// Errors generated during the process of retrieving the localization resources
147#[derive(Debug, Error)]
148pub enum ResourceManagerError {
149    /// Error while reading the resource file
150    #[error("{0}")]
151    Io(#[from] std::io::Error),
152
153    /// Error while trying to add a resource to the bundle
154    #[error("{0}")]
155    Fluent(#[from] fluent_bundle::FluentError),
156}
157
158// Due to limitation of trait, we need a nameable Iterator type.  Due to the
159// lack of GATs, these have to own members instead of taking slices.
160pub struct BundleIter {
161    locales: <Vec<LanguageIdentifier> as IntoIterator>::IntoIter,
162    res_ids: FxHashSet<ResourceId>,
163}
164
165impl Iterator for BundleIter {
166    type Item = FluentBundleResult<FluentResource>;
167
168    fn next(&mut self) -> Option<Self::Item> {
169        let locale = self.locales.next()?;
170
171        let mut bundle = FluentBundle::new(vec![locale.clone()]);
172
173        for res_id in self.res_ids.iter() {
174            let full_path = format!("./tests/resources/{}/{}", locale, res_id);
175            let source = fs::read_to_string(full_path).unwrap();
176            let res = FluentResource::try_new(source).unwrap();
177            bundle.add_resource(res).unwrap();
178        }
179        Some(Ok(bundle))
180    }
181}
182
183// TODO - These need to be implemented.
184// https://github.com/projectfluent/fluent-rs/issues/281
185
186// coverage(off)
187impl Stream for BundleIter {
188    type Item = FluentBundleResult<FluentResource>;
189
190    fn poll_next(
191        self: std::pin::Pin<&mut Self>,
192        _cx: &mut std::task::Context<'_>,
193    ) -> std::task::Poll<Option<Self::Item>> {
194        todo!()
195    }
196}
197
198impl BundleGenerator for ResourceManager {
199    type Resource = FluentResource;
200    type LocalesIter = std::vec::IntoIter<LanguageIdentifier>;
201    type Iter = BundleIter;
202    type Stream = BundleIter;
203
204    fn bundles_iter(
205        &self,
206        locales: Self::LocalesIter,
207        res_ids: FxHashSet<ResourceId>,
208    ) -> Self::Iter {
209        BundleIter { locales, res_ids }
210    }
211
212    fn bundles_stream(
213        &self,
214        _locales: Self::LocalesIter,
215        _res_ids: FxHashSet<ResourceId>,
216    ) -> Self::Stream {
217        todo!()
218    }
219}
220// coverage(on)
221
222#[cfg(test)]
223mod test {
224    use super::*;
225    use unic_langid::langid;
226
227    #[test]
228    fn caching() {
229        let res_mgr = ResourceManager::new("./tests/resources/{locale}/{res_id}".into());
230
231        let _bundle = res_mgr.get_bundle(vec![langid!("en-US")], vec!["test.ftl".into()]);
232        let res_1 = res_mgr
233            .get_resource("test.ftl", "en-US")
234            .expect("Could not get resource");
235
236        let _bundle2 = res_mgr.get_bundle(vec![langid!("en-US")], vec!["test.ftl".into()]);
237        let res_2 = res_mgr
238            .get_resource("test.ftl", "en-US")
239            .expect("Could not get resource");
240
241        assert!(
242            std::ptr::eq(res_1, res_2),
243            "The resources are cached in memory and reference the same thing."
244        );
245    }
246
247    #[test]
248    fn get_resource_error() {
249        let res_mgr = ResourceManager::new("./tests/resources/{locale}/{res_id}".into());
250
251        let _bundle = res_mgr.get_bundle(vec![langid!("en-US")], vec!["test.ftl".into()]);
252        let res = res_mgr.get_resource("nonexistent.ftl", "en-US");
253
254        assert!(res.is_err());
255    }
256
257    #[test]
258    fn get_bundle_error() {
259        let res_mgr = ResourceManager::new("./tests/resources/{locale}/{res_id}".into());
260        let bundle = res_mgr.get_bundle(vec![langid!("en-US")], vec!["nonexistent.ftl".into()]);
261
262        assert!(bundle.is_err());
263    }
264
265    // TODO - Syntax errors should be surfaced. This test has an invalid resource that
266    // should fail, but currently isn't.
267    // https://github.com/projectfluent/fluent-rs/issues/280
268    #[test]
269    fn get_bundle_ignores_errors() {
270        let res_mgr = ResourceManager::new("./tests/resources/{locale}/{res_id}".into());
271        let bundle = res_mgr
272            .get_bundle(
273                vec![langid!("en-US")],
274                vec!["test.ftl".into(), "invalid.ftl".into()],
275            )
276            .expect("Could not retrieve bundle");
277
278        let mut errors = vec![];
279        let msg = bundle.get_message("hello-world").expect("Message exists");
280        let pattern = msg.value().expect("Message has a value");
281        let value = bundle.format_pattern(pattern, None, &mut errors);
282        assert_eq!(value, "Hello World");
283        assert!(errors.is_empty());
284
285        let mut errors = vec![];
286        let msg = bundle.get_message("valid-message").expect("Message exists");
287        let pattern = msg.value().expect("Message has a value");
288        let value = bundle.format_pattern(pattern, None, &mut errors);
289        assert_eq!(value, "This is a valid message");
290        assert!(errors.is_empty());
291    }
292}