fluent_localization_loader/
lib.rs

1use std::{collections::HashMap, env, error::Error, fmt::Display, fs, path::PathBuf, sync::Arc};
2
3use fluent_bundle::{bundle::FluentBundle as RawBundle, FluentResource};
4
5use anyhow::{Context, Result};
6use fluent_syntax::parser::ParserError;
7use intl_memoizer::concurrent::IntlLangMemoizer;
8use tracing::{debug, error, trace, warn};
9use unic_langid::LanguageIdentifier;
10
11type FluentBundle = RawBundle<Arc<FluentResource>, IntlLangMemoizer>;
12
13pub const FILE_EXTENSION: &str = ".ftl";
14pub const DEFAULT_DIR: &str = "default";
15
16///Basic wrapper to hold a resource and its original filename
17#[derive(Clone)]
18pub struct Resource {
19    pub name: String,
20    pub resource: Arc<FluentResource>,
21}
22
23/// Holder to hold all the loaded bundled for localizations, as well as the currently configured default language
24pub struct LocalizationHolder {
25    // Store the identifiers as strings so we don't need to convert every time we need to translate something
26    pub bundles: HashMap<String, FluentBundle>,
27    pub default_language: String,
28}
29#[derive(Debug)]
30pub struct LocalizationLoadingError {
31    error: String,
32}
33
34impl LocalizationLoadingError {
35    pub fn new(error: String) -> Self {
36        LocalizationLoadingError { error }
37    }
38}
39
40impl Error for LocalizationLoadingError {}
41
42impl Display for LocalizationLoadingError {
43    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
44        f.write_str(&self.error)
45    }
46}
47
48impl LocalizationHolder {
49    pub fn load() -> Result<Self> {
50        let base_path = base_path();
51        debug!(
52            "Loading localizations from {}",
53            base_path.as_path().to_string_lossy()
54        );
55        let mut bundles = HashMap::new();
56
57        let default_identifier = get_default_language()?;
58        let default = default_identifier.to_string();
59
60        let mut path = base_path.clone();
61        path.push(DEFAULT_DIR);
62
63        let defaults = load_resources_from_folder(path)?;
64
65        // Walk through the directory and start assembling our localizer
66        let base_handle =
67            fs::read_dir(base_path.clone()).context("Failed to read localizations base dir")?;
68
69        for result in base_handle {
70            let item_handle = result.context(
71                "Failed to get a handle when walking through the localizations directory",
72            )?;
73            //Store the file name in a var separately before the actual name is stored so it isn't dropped early
74            let underlying_name = item_handle.file_name();
75            let lang_name = underlying_name.to_string_lossy();
76
77            let meta = item_handle
78                .file_type()
79                .with_context(|| format!("Failed to get item metadata for {lang_name}"))?;
80
81            if !meta.is_dir() {
82                trace!("Skipping {lang_name} because it is not a directory");
83                continue;
84            }
85
86            let Ok(identifier) = lang_name.parse::<LanguageIdentifier>() else {
87                warn!("Skipping {lang_name} because it is not a valid language identifier");
88                continue;
89            };
90
91            let bundle = load_bundle(base_path.clone(), identifier, defaults.clone())?;
92
93            // finally add the bundle to the map
94            bundles.insert(lang_name.to_string(), bundle);
95        }
96
97        Ok(LocalizationHolder {
98            bundles,
99            default_language: default,
100        })
101    }
102
103    pub fn get_bundle(&self, language: &str) -> &FluentBundle {
104        self.bundles
105            .get(language)
106            .unwrap_or_else(|| self.get_bundle(&self.default_language))
107    }
108
109    pub fn get_default_bundle(&self) -> &FluentBundle {
110        self.bundles.get(&self.default_language).unwrap()
111    }
112}
113
114/// The base path localizations will be loaded from, this is controlled by the `TRANSLATION_DIR` environment variable;
115/// Will default to the `localizations` subfolder of the current working directory if not set
116pub fn base_path() -> PathBuf {
117    match env::var("TRANSLATION_DIR") {
118        Ok(location) => PathBuf::from(location),
119        Err(_) => {
120            let mut buf =
121                env::current_dir().expect("Failed to get current working directory from std::env");
122            buf.push("localizations");
123            buf
124        }
125    }
126}
127
128/// Get the current default language, this is controlled by the `DEFAULT_LANG` environment variable.
129/// Will default to `DEFAULT` if not set
130pub fn get_default_language() -> Result<LanguageIdentifier> {
131    let value = env::var("DEFAULT_LANG").unwrap_or("en_US".to_string());
132    value
133        .parse::<LanguageIdentifier>()
134        .with_context(|| format!("Invalid default langauge: {value}"))
135}
136
137/// Load all fluent resource files from a directory and returns them.
138/// Only files with an .ftl extension will be loaded, does not load files from subfolders
139///
140/// Generally you don't want to be using this but rather use the load function to get an
141/// LocalizationHolder with localizations for all your languages
142///
143/// However this is public for the purposes of generating bindings through the ... crate, if if you want to do it yourself
144/// # Arguments
145/// * `path` - A PathBuf to the folder to load the resources from
146pub fn load_resources_from_folder(path: PathBuf) -> Result<Vec<Resource>> {
147    trace!("Loading resources from {path:?}");
148    let p = path.clone();
149    let path_name = p.to_string_lossy();
150    let mut loaded = Vec::new();
151
152    // Loop over all files in the directory and add them to the bundle
153    let lang_dir = fs::read_dir(path)
154        .with_context(|| format!("Failed to read localization directory {path_name}"))?;
155
156    for result in lang_dir {
157        let item_handle = result.with_context(|| {
158            format!("Failed to get a file handle when walking through the {path_name} directory")
159        })?;
160
161        let underlying_name = item_handle.file_name();
162        let name = underlying_name.to_string_lossy();
163        let meta = item_handle
164            .file_type()
165            .with_context(|| format!("Failed to get item metadata for {path_name}/{name}"))?;
166
167        if !meta.is_file() {
168            debug!("Skipping {path_name}/{name} because it is not a file");
169            continue;
170        }
171
172        if !name.ends_with(FILE_EXTENSION) {
173            warn!("Skipping {path_name}/{name} because it doesn't have the proper {FILE_EXTENSION} extension");
174            continue;
175        }
176
177        trace!("Loading localization file {path_name}/{name}");
178        let file_content = fs::read_to_string(item_handle.path())
179            .with_context(|| format!("Failed to load localization file {path_name}/{name}"))?;
180
181        let fluent_resource = FluentResource::try_new(file_content.clone())
182            .map_err(|(_, error_list)| {
183                LocalizationLoadingError::new(fold_displayable(
184                    error_list
185                        .into_iter()
186                        .map(|e| prettify_parse_error(&file_content, e)),
187                    "\n-----\n",
188                ))
189            })
190            .with_context(|| format!("Failed to load localization file {path_name}/{name}"))?;
191
192        let arced = Arc::new(fluent_resource);
193
194        loaded.push(Resource {
195            name: name.strip_suffix(FILE_EXTENSION).unwrap().to_string(),
196            resource: arced,
197        })
198    }
199
200    Ok(loaded)
201}
202
203fn load_bundle(
204    mut base_path: PathBuf,
205    identifier: LanguageIdentifier,
206    defaults: Vec<Resource>,
207) -> Result<FluentBundle> {
208    let lang_name = identifier.to_string();
209    trace!("Loading language {lang_name}");
210    base_path.push(&lang_name);
211
212    let mut bundle = FluentBundle::new_concurrent(Vec::from_iter([identifier.clone()]));
213
214    // to test against duplicate keys
215    let mut test_bundle = FluentBundle::new_concurrent(Vec::from_iter([identifier]));
216
217    for default in defaults {
218        bundle.add_resource_overriding(default.resource)
219    }
220
221    for resource in load_resources_from_folder(base_path)? {
222        // First we add to the test bundle that does not have defaults, so we get errors if there are duplicate keys across the files (shouldn't happen, but ya know. me proofing)
223        test_bundle.add_resource(resource.resource.clone()).map_err(|error_list| {
224            LocalizationLoadingError::new(fold_displayable(
225        error_list
226            .into_iter()
227            // This should only ever yield overriding errors so lets keep this simple
228            .map(|e| e.to_string()),
229        "\n-----\n",
230    ))
231})
232.with_context(|| format!("Failed to load localization file {lang_name}/{} into the duplicate test bundle", resource.name))?;
233
234        bundle.add_resource_overriding(resource.resource)
235    }
236
237    Ok(bundle)
238}
239
240fn prettify_parse_error(file_content: &str, e: ParserError) -> String {
241    // figure out where our line endings are to show something at least a little more useful
242    let mut line_endings = file_content.lines().map(|line| (line.len(), line));
243
244    let mut pos = 0;
245    let mut line = "";
246    let mut line_count = 0;
247
248    loop {
249        let Some((len, l)) = line_endings.next() else {
250            error!("Somehow fluent-rs reported a error that is past the end of the file? Was at pos {}, looking for {}", pos, e.pos.start);
251            break;
252        };
253
254        //store the current line
255        line = l;
256        line_count += 1;
257
258        // are we there yet?
259        if pos + len > e.pos.start {
260            break;
261        }
262
263        pos += len;
264    }
265
266    let line_pos = e.pos.start - pos;
267
268    format!("{} at {line_count}:{line_pos}\n    {line}", e.kind)
269}
270
271#[doc(hidden)]
272pub fn fold_displayable(
273    mut iterator: impl Iterator<Item = impl Display>,
274    separator: &str,
275) -> String {
276    let Some(first) = iterator.next() else {
277        return String::new();
278    };
279    iterator.fold(first.to_string(), |assembled, new| {
280        assembled + separator + &new.to_string()
281    })
282}