1//! A builder for gettext configuration.
2
3use locale_config::{LanguageRange, Locale};
4
5use std::env;
6use std::error;
7use std::fmt;
8use std::fs;
9use std::path::PathBuf;
10
11use super::{bind_textdomain_codeset, bindtextdomain, setlocale, textdomain, LocaleCategory};
12
13/// Errors that might come up after running the builder.
14#[derive(Debug)]
15pub 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
28impl 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
48impl 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
154pub 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
164impl 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
390fn 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
404impl 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)]
432mod 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