1 | //! A builder for gettext configuration. |
2 | |
3 | use locale_config::{LanguageRange, Locale}; |
4 | |
5 | use std::env; |
6 | use std::error; |
7 | use std::fmt; |
8 | use std::fs; |
9 | use std::path::PathBuf; |
10 | |
11 | use super::{bind_textdomain_codeset, bindtextdomain, setlocale, textdomain, LocaleCategory}; |
12 | |
13 | /// Errors that might come up after running the builder. |
14 | #[derive (Debug)] |
15 | pub enum TextDomainError { |
16 | /// The locale is malformed. |
17 | InvalidLocale(String), |
18 | /// The translation for the requested language could not be found or the search path is empty. |
19 | TranslationNotFound(String), |
20 | /// The call to `textdomain()` failed. |
21 | TextDomainCallFailed(std::io::Error), |
22 | /// The call to `bindtextdomain()` failed. |
23 | BindTextDomainCallFailed(std::io::Error), |
24 | /// The call to `bind_textdomain_codeset()` failed. |
25 | BindTextDomainCodesetCallFailed(std::io::Error), |
26 | } |
27 | |
28 | impl fmt::Display for TextDomainError { |
29 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { |
30 | use TextDomainError::*; |
31 | |
32 | match self { |
33 | InvalidLocale(locale: &String) => write!(f, r#"Locale " {}" is invalid."# , locale), |
34 | TranslationNotFound(language: &String) => { |
35 | write!(f, "Translations not found for language {}." , language) |
36 | } |
37 | TextDomainCallFailed(inner: &Error) => write!(f, "The call to textdomain() failed: {}" , inner), |
38 | BindTextDomainCallFailed(inner: &Error) => { |
39 | write!(f, "The call to bindtextdomain() failed: {}" , inner) |
40 | } |
41 | BindTextDomainCodesetCallFailed(inner: &Error) => { |
42 | write!(f, "The call to bind_textdomain_codeset() failed: {}" , inner) |
43 | } |
44 | } |
45 | } |
46 | } |
47 | |
48 | impl error::Error for TextDomainError { |
49 | fn source(&self) -> Option<&(dyn error::Error + 'static)> { |
50 | use TextDomainError::*; |
51 | |
52 | match self { |
53 | InvalidLocale(_) => None, |
54 | TranslationNotFound(_) => None, |
55 | TextDomainCallFailed(inner: &Error) => Some(inner), |
56 | BindTextDomainCallFailed(inner: &Error) => Some(inner), |
57 | BindTextDomainCodesetCallFailed(inner: &Error) => Some(inner), |
58 | } |
59 | } |
60 | } |
61 | |
62 | /// A builder to configure gettext. |
63 | /// |
64 | /// It searches translations in the system data paths and optionally in the user-specified paths, |
65 | /// and binds them to the given domain. `TextDomain` takes care of calling [`setlocale`], |
66 | /// [`bindtextdomain`], [`bind_textdomain_codeset`], and [`textdomain`] for you. |
67 | /// |
68 | /// # Defaults |
69 | /// |
70 | /// - [`bind_textdomain_codeset`] is called by default to set UTF-8. You can use [`codeset`] to |
71 | /// override this, but please bear in mind that [other functions in this crate require |
72 | /// UTF-8](./index.html#utf-8-is-required). |
73 | /// - Current user's locale is selected by default. You can override this behaviour by calling |
74 | /// [`locale`]. |
75 | /// - [`LocaleCategory::LcMessages`] is used when calling [`setlocale`]. Use [`locale_category`] |
76 | /// to override. |
77 | /// - System data paths are searched by default (see below for details). Use |
78 | /// [`skip_system_data_paths`] to limit the search to user-provided paths. |
79 | /// |
80 | /// # Text domain path binding |
81 | /// |
82 | /// A translation file for the text domain is searched in the following paths (in order): |
83 | /// |
84 | /// 1. Paths added using the [`prepend`] function. |
85 | /// 1. Paths from the `XDG_DATA_DIRS` environment variable, except if the function |
86 | /// [`skip_system_data_paths`] was invoked. If `XDG_DATA_DIRS` is not set, or is empty, the default |
87 | /// of "/usr/local/share/:/usr/share/" is used. |
88 | /// 1. Paths added using the [`push`] function. |
89 | /// |
90 | /// For each `path` in the search paths, the following subdirectories are scanned: |
91 | /// `path/locale/lang*/LC_MESSAGES` (where `lang` is the language part of the selected locale). |
92 | /// The first `path` containing a file matching `domainname.mo` is used for the call to |
93 | /// [`bindtextdomain`]. |
94 | /// |
95 | /// # Examples |
96 | /// |
97 | /// Basic usage: |
98 | /// |
99 | /// ```no_run |
100 | /// use gettextrs::TextDomain; |
101 | /// |
102 | /// # fn main() -> Result<(), Box<dyn std::error::Error>> { |
103 | /// TextDomain::new("my_textdomain" ).init()?; |
104 | /// # Ok(()) |
105 | /// # } |
106 | /// ``` |
107 | /// |
108 | /// Use the translation in current language under the `target` directory if available, otherwise |
109 | /// search system defined paths: |
110 | /// |
111 | /// ```no_run |
112 | /// use gettextrs::TextDomain; |
113 | /// |
114 | /// # fn main() -> Result<(), Box<dyn std::error::Error>> { |
115 | /// TextDomain::new("my_textdomain" ) |
116 | /// .prepend("target" ) |
117 | /// .init()?; |
118 | /// # Ok(()) |
119 | /// # } |
120 | /// ``` |
121 | /// |
122 | /// Scan the `target` directory only, force locale to `fr_FR` and handle errors: |
123 | /// |
124 | /// ```no_run |
125 | /// use gettextrs::{TextDomain, TextDomainError}; |
126 | /// |
127 | /// let init_msg = match TextDomain::new("my_textdomain" ) |
128 | /// .skip_system_data_paths() |
129 | /// .push("target" ) |
130 | /// .locale("fr_FR" ) |
131 | /// .init() |
132 | /// { |
133 | /// Ok(locale) => { |
134 | /// format!("translation found, `setlocale` returned {:?}" , locale) |
135 | /// } |
136 | /// Err(error) => { |
137 | /// format!("an error occurred: {}" , error) |
138 | /// } |
139 | /// }; |
140 | /// println!("Textdomain init result: {}" , init_msg); |
141 | /// ``` |
142 | /// |
143 | /// [`setlocale`]: fn.setlocale.html |
144 | /// [`bindtextdomain`]: fn.bindtextdomain.html |
145 | /// [`bind_textdomain_codeset`]: fn.bind_textdomain_codeset.html |
146 | /// [`textdomain`]: fn.textdomain.html |
147 | /// [`LocaleCategory::LcMessages`]: enum.LocaleCategory.html#variant.LcMessages |
148 | /// [`locale`]: struct.TextDomain.html#method.locale |
149 | /// [`locale_category`]: struct.TextDomain.html#method.locale_category |
150 | /// [`codeset`]: struct.TextDomain.html#method.codeset |
151 | /// [`skip_system_data_paths`]: struct.TextDomain.html#method.skip_system_data_paths |
152 | /// [`prepend`]: struct.TextDomain.html#method.prepend |
153 | /// [`push`]: struct.TextDomain.html#method.push |
154 | pub struct TextDomain { |
155 | domainname: String, |
156 | locale: Option<String>, |
157 | locale_category: LocaleCategory, |
158 | codeset: String, |
159 | pre_paths: Vec<PathBuf>, |
160 | post_paths: Vec<PathBuf>, |
161 | skip_system_data_paths: bool, |
162 | } |
163 | |
164 | impl TextDomain { |
165 | /// Creates a new instance of `TextDomain` for the specified `domainname`. |
166 | /// |
167 | /// # Examples |
168 | /// |
169 | /// ```no_run |
170 | /// use gettextrs::TextDomain; |
171 | /// |
172 | /// let text_domain = TextDomain::new("my_textdomain" ); |
173 | /// ``` |
174 | pub fn new<S: Into<String>>(domainname: S) -> TextDomain { |
175 | TextDomain { |
176 | domainname: domainname.into(), |
177 | locale: None, |
178 | locale_category: LocaleCategory::LcMessages, |
179 | codeset: "UTF-8" .to_string(), |
180 | pre_paths: vec![], |
181 | post_paths: vec![], |
182 | skip_system_data_paths: false, |
183 | } |
184 | } |
185 | |
186 | /// Override the `locale` for the `TextDomain`. Default is to use current locale. |
187 | /// |
188 | /// # Examples |
189 | /// |
190 | /// ```no_run |
191 | /// use gettextrs::TextDomain; |
192 | /// |
193 | /// let text_domain = TextDomain::new("my_textdomain" ) |
194 | /// .locale("fr_FR.UTF-8" ); |
195 | /// ``` |
196 | pub fn locale(mut self, locale: &str) -> Self { |
197 | self.locale = Some(locale.to_owned()); |
198 | self |
199 | } |
200 | |
201 | /// Override the `locale_category`. Default is [`LocaleCategory::LcMessages`]. |
202 | /// |
203 | /// # Examples |
204 | /// |
205 | /// ```no_run |
206 | /// use gettextrs::{LocaleCategory, TextDomain}; |
207 | /// |
208 | /// let text_domain = TextDomain::new("my_textdomain" ) |
209 | /// .locale_category(LocaleCategory::LcAll); |
210 | /// ``` |
211 | /// |
212 | /// [`LocaleCategory::LcMessages`]: enum.LocaleCategory.html#variant.LcMessages |
213 | pub fn locale_category(mut self, locale_category: LocaleCategory) -> Self { |
214 | self.locale_category = locale_category; |
215 | self |
216 | } |
217 | |
218 | /// Define the `codeset` that will be used for calling [`bind_textdomain_codeset`]. The default |
219 | /// is "UTF-8". |
220 | /// |
221 | /// **Warning:** [other functions in this crate require UTF-8](./index.html#utf-8-is-required). |
222 | /// |
223 | /// # Examples |
224 | /// |
225 | /// ```no_run |
226 | /// use gettextrs::TextDomain; |
227 | /// |
228 | /// let text_domain = TextDomain::new("my_textdomain" ) |
229 | /// .codeset("KOI8-R" ); |
230 | /// ``` |
231 | /// |
232 | /// [`bind_textdomain_codeset`]: fn.bind_textdomain_codeset.html |
233 | pub fn codeset<S: Into<String>>(mut self, codeset: S) -> Self { |
234 | self.codeset = codeset.into(); |
235 | self |
236 | } |
237 | |
238 | /// Prepend the given `path` to the search paths. |
239 | /// |
240 | /// # Examples |
241 | /// |
242 | /// ```no_run |
243 | /// use gettextrs::TextDomain; |
244 | /// |
245 | /// let text_domain = TextDomain::new("my_textdomain" ) |
246 | /// .prepend("~/.local/share" ); |
247 | /// ``` |
248 | pub fn prepend<P: Into<PathBuf>>(mut self, path: P) -> Self { |
249 | self.pre_paths.push(path.into()); |
250 | self |
251 | } |
252 | |
253 | /// Push the given `path` to the end of the search paths. |
254 | /// |
255 | /// # Examples |
256 | /// |
257 | /// ```no_run |
258 | /// use gettextrs::TextDomain; |
259 | /// |
260 | /// let text_domain = TextDomain::new("my_textdomain" ) |
261 | /// .push("test" ); |
262 | /// ``` |
263 | pub fn push<P: Into<PathBuf>>(mut self, path: P) -> Self { |
264 | self.post_paths.push(path.into()); |
265 | self |
266 | } |
267 | |
268 | /// Don't search for translations in the system data paths. |
269 | /// |
270 | /// # Examples |
271 | /// |
272 | /// ```no_run |
273 | /// use gettextrs::TextDomain; |
274 | /// |
275 | /// let text_domain = TextDomain::new("my_textdomain" ) |
276 | /// .push("test" ) |
277 | /// .skip_system_data_paths(); |
278 | /// ``` |
279 | pub fn skip_system_data_paths(mut self) -> Self { |
280 | self.skip_system_data_paths = true; |
281 | self |
282 | } |
283 | |
284 | /// Search for translations in the search paths, initialize the locale, set up the text domain |
285 | /// and ask gettext to convert messages to UTF-8. |
286 | /// |
287 | /// Returns an `Option` with the opaque string that describes the locale set (i.e. the result |
288 | /// of [`setlocale`]) if: |
289 | /// |
290 | /// - a translation of the text domain in the requested language was found; and |
291 | /// - the locale is valid. |
292 | /// |
293 | /// # Examples |
294 | /// |
295 | /// ```no_run |
296 | /// use gettextrs::TextDomain; |
297 | /// |
298 | /// # fn main() -> Result<(), Box<dyn std::error::Error>> { |
299 | /// TextDomain::new("my_textdomain" ).init()?; |
300 | /// # Ok(()) |
301 | /// # } |
302 | /// ``` |
303 | /// |
304 | /// [`TextDomainError`]: enum.TextDomainError.html |
305 | /// [`setlocale`]: fn.setlocale.html |
306 | pub fn init(mut self) -> Result<Option<Vec<u8>>, TextDomainError> { |
307 | let (req_locale, norm_locale) = match self.locale.take() { |
308 | Some(req_locale) => { |
309 | if req_locale == "C" || req_locale == "POSIX" { |
310 | return Ok(Some(req_locale.as_bytes().to_owned())); |
311 | } |
312 | match LanguageRange::new(&req_locale) { |
313 | Ok(lang_range) => (req_locale.clone(), lang_range.into()), |
314 | Err(_) => { |
315 | // try again as unix language tag |
316 | match LanguageRange::from_unix(&req_locale) { |
317 | Ok(lang_range) => (req_locale.clone(), lang_range.into()), |
318 | Err(_) => { |
319 | return Err(TextDomainError::InvalidLocale(req_locale.clone())); |
320 | } |
321 | } |
322 | } |
323 | } |
324 | } |
325 | None => { |
326 | // `setlocale` accepts an empty string for current locale |
327 | ("" .to_owned(), Locale::current()) |
328 | } |
329 | }; |
330 | |
331 | let lang = norm_locale.as_ref().splitn(2, "-" ).collect::<Vec<&str>>()[0].to_owned(); |
332 | |
333 | let domainname = self.domainname; |
334 | let locale_category = self.locale_category; |
335 | let codeset = self.codeset; |
336 | |
337 | let mo_rel_path = PathBuf::from("LC_MESSAGES" ).join(&format!(" {}.mo" , &domainname)); |
338 | |
339 | // Get paths from system data dirs if requested so |
340 | let sys_data_paths_str = if !self.skip_system_data_paths { |
341 | get_system_data_paths() |
342 | } else { |
343 | "" .to_owned() |
344 | }; |
345 | let sys_data_dirs_iter = env::split_paths(&sys_data_paths_str); |
346 | |
347 | // Chain search paths and search for the translation mo file |
348 | self.pre_paths |
349 | .into_iter() |
350 | .chain(sys_data_dirs_iter) |
351 | .chain(self.post_paths.into_iter()) |
352 | .find(|path| { |
353 | let locale_path = path.join("locale" ); |
354 | if !locale_path.is_dir() { |
355 | return false; |
356 | } |
357 | |
358 | // path contains a `locale` directory |
359 | // search for sub directories matching `lang*` |
360 | // and see if we can find a translation file for the `textdomain` |
361 | // under `path/locale/lang*/LC_MESSAGES/` |
362 | if let Ok(entry_iter) = fs::read_dir(&locale_path) { |
363 | return entry_iter |
364 | .filter_map(|entry_res| entry_res.ok()) |
365 | .filter(|entry| matches!(entry.file_type().map(|ft| ft.is_dir()), Ok(true))) |
366 | .any(|entry| { |
367 | if let Some(entry_name) = entry.file_name().to_str() { |
368 | return entry_name.starts_with(&lang) |
369 | && locale_path.join(entry_name).join(&mo_rel_path).exists(); |
370 | } |
371 | |
372 | false |
373 | }); |
374 | } |
375 | |
376 | false |
377 | }) |
378 | .map_or(Err(TextDomainError::TranslationNotFound(lang)), |path| { |
379 | let result = setlocale(locale_category, req_locale); |
380 | bindtextdomain(domainname.clone(), path.join("locale" )) |
381 | .map_err(TextDomainError::BindTextDomainCallFailed)?; |
382 | bind_textdomain_codeset(domainname.clone(), codeset) |
383 | .map_err(TextDomainError::BindTextDomainCodesetCallFailed)?; |
384 | textdomain(domainname).map_err(TextDomainError::TextDomainCallFailed)?; |
385 | Ok(result) |
386 | }) |
387 | } |
388 | } |
389 | |
390 | fn get_system_data_paths() -> String { |
391 | static DEFAULT: &str = "/usr/local/share/:/usr/share/" ; |
392 | |
393 | if let Ok(dirs: String) = env::var(key:"XDG_DATA_DIRS" ) { |
394 | if dirs.is_empty() { |
395 | DEFAULT.to_owned() |
396 | } else { |
397 | dirs |
398 | } |
399 | } else { |
400 | DEFAULT.to_owned() |
401 | } |
402 | } |
403 | |
404 | impl fmt::Debug for TextDomain { |
405 | fn fmt(&self, fmt: &mut fmt::Formatter) -> fmt::Result { |
406 | let mut debug_struct = fmt.debug_struct("TextDomain" ); |
407 | debug_struct |
408 | .field("domainname" , &self.domainname) |
409 | .field( |
410 | "locale" , |
411 | &match self.locale.as_ref() { |
412 | Some(locale) => locale.to_owned(), |
413 | None => { |
414 | let cur_locale = Locale::current(); |
415 | cur_locale.as_ref().to_owned() |
416 | } |
417 | }, |
418 | ) |
419 | .field("locale_category" , &self.locale_category) |
420 | .field("codeset" , &self.codeset) |
421 | .field("pre_paths" , &self.pre_paths); |
422 | |
423 | if !self.skip_system_data_paths { |
424 | debug_struct.field("using system data paths" , &get_system_data_paths()); |
425 | } |
426 | |
427 | debug_struct.field("post_paths" , &self.post_paths).finish() |
428 | } |
429 | } |
430 | |
431 | #[cfg (test)] |
432 | mod tests { |
433 | use super::{LocaleCategory, TextDomain, TextDomainError}; |
434 | |
435 | #[test ] |
436 | fn errors() { |
437 | match TextDomain::new("test" ).locale("(°_°)" ).init().err() { |
438 | Some(TextDomainError::InvalidLocale(message)) => assert_eq!(message, "(°_°)" ), |
439 | _ => panic!(), |
440 | }; |
441 | |
442 | match TextDomain::new("0_0" ).locale("en_US" ).init().err() { |
443 | Some(TextDomainError::TranslationNotFound(message)) => assert_eq!(message, "en" ), |
444 | _ => panic!(), |
445 | }; |
446 | } |
447 | |
448 | #[test ] |
449 | fn attributes() { |
450 | let text_domain = TextDomain::new("test" ); |
451 | assert_eq!("test" .to_owned(), text_domain.domainname); |
452 | assert!(text_domain.locale.is_none()); |
453 | assert_eq!(LocaleCategory::LcMessages, text_domain.locale_category); |
454 | assert_eq!(text_domain.codeset, "UTF-8" ); |
455 | assert!(text_domain.pre_paths.is_empty()); |
456 | assert!(text_domain.post_paths.is_empty()); |
457 | assert!(!text_domain.skip_system_data_paths); |
458 | |
459 | let text_domain = text_domain.locale_category(LocaleCategory::LcAll); |
460 | assert_eq!(LocaleCategory::LcAll, text_domain.locale_category); |
461 | |
462 | let text_domain = text_domain.codeset("ISO-8859-15" ); |
463 | assert_eq!("ISO-8859-15" , text_domain.codeset); |
464 | |
465 | let text_domain = text_domain.prepend("pre" ); |
466 | assert!(!text_domain.pre_paths.is_empty()); |
467 | |
468 | let text_domain = text_domain.push("post" ); |
469 | assert!(!text_domain.post_paths.is_empty()); |
470 | |
471 | let text_domain = text_domain.skip_system_data_paths(); |
472 | assert!(text_domain.skip_system_data_paths); |
473 | |
474 | let text_domain = TextDomain::new("test" ).locale("en_US" ); |
475 | assert_eq!(Some("en_US" .to_owned()), text_domain.locale); |
476 | |
477 | // accept locale, but fail to find translation |
478 | match TextDomain::new("0_0" ).locale("en_US" ).init().err() { |
479 | Some(TextDomainError::TranslationNotFound(message)) => assert_eq!(message, "en" ), |
480 | _ => panic!(), |
481 | }; |
482 | } |
483 | } |
484 | |