fluent_localization_loader/
lib.rs1use 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#[derive(Clone)]
18pub struct Resource {
19 pub name: String,
20 pub resource: Arc<FluentResource>,
21}
22
23pub struct LocalizationHolder {
25 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 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 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 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
114pub 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
128pub 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
137pub 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 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 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 test_bundle.add_resource(resource.resource.clone()).map_err(|error_list| {
224 LocalizationLoadingError::new(fold_displayable(
225 error_list
226 .into_iter()
227 .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 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 line = l;
256 line_count += 1;
257
258 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}