gettextrs/
text_domain.rs

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) => write!(f, r#"Locale "{}" is invalid."#, locale),
34            TranslationNotFound(language) => {
35                write!(f, "Translations not found for language {}.", language)
36            }
37            TextDomainCallFailed(inner) => write!(f, "The call to textdomain() failed: {}", inner),
38            BindTextDomainCallFailed(inner) => {
39                write!(f, "The call to bindtextdomain() failed: {}", inner)
40            }
41            BindTextDomainCodesetCallFailed(inner) => {
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) => Some(inner),
56            BindTextDomainCallFailed(inner) => Some(inner),
57            BindTextDomainCodesetCallFailed(inner) => 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| {
366                            matches!(
367                                entry.file_type().map(|ft| ft.is_dir() || ft.is_symlink()),
368                                Ok(true)
369                            )
370                        })
371                        .any(|entry| {
372                            if let Some(entry_name) = entry.file_name().to_str() {
373                                return entry_name.starts_with(&lang)
374                                    && locale_path.join(entry_name).join(&mo_rel_path).exists();
375                            }
376
377                            false
378                        });
379                }
380
381                false
382            })
383            .map_or(Err(TextDomainError::TranslationNotFound(lang)), |path| {
384                let result = setlocale(locale_category, req_locale);
385                bindtextdomain(domainname.clone(), path.join("locale"))
386                    .map_err(TextDomainError::BindTextDomainCallFailed)?;
387                bind_textdomain_codeset(domainname.clone(), codeset)
388                    .map_err(TextDomainError::BindTextDomainCodesetCallFailed)?;
389                textdomain(domainname).map_err(TextDomainError::TextDomainCallFailed)?;
390                Ok(result)
391            })
392    }
393}
394
395fn get_system_data_paths() -> String {
396    static DEFAULT: &str = "/usr/local/share/:/usr/share/";
397
398    if let Ok(dirs) = env::var("XDG_DATA_DIRS") {
399        if dirs.is_empty() {
400            DEFAULT.to_owned()
401        } else {
402            dirs
403        }
404    } else {
405        DEFAULT.to_owned()
406    }
407}
408
409impl fmt::Debug for TextDomain {
410    fn fmt(&self, fmt: &mut fmt::Formatter) -> fmt::Result {
411        let mut debug_struct = fmt.debug_struct("TextDomain");
412        debug_struct
413            .field("domainname", &self.domainname)
414            .field(
415                "locale",
416                &match self.locale.as_ref() {
417                    Some(locale) => locale.to_owned(),
418                    None => {
419                        let cur_locale = Locale::current();
420                        cur_locale.as_ref().to_owned()
421                    }
422                },
423            )
424            .field("locale_category", &self.locale_category)
425            .field("codeset", &self.codeset)
426            .field("pre_paths", &self.pre_paths);
427
428        if !self.skip_system_data_paths {
429            debug_struct.field("using system data paths", &get_system_data_paths());
430        }
431
432        debug_struct.field("post_paths", &self.post_paths).finish()
433    }
434}
435
436#[cfg(test)]
437mod tests {
438    use super::{LocaleCategory, TextDomain, TextDomainError};
439
440    #[test]
441    fn errors() {
442        match TextDomain::new("test").locale("(°_°)").init().err() {
443            Some(TextDomainError::InvalidLocale(message)) => assert_eq!(message, "(°_°)"),
444            _ => panic!(),
445        };
446
447        match TextDomain::new("0_0").locale("en_US").init().err() {
448            Some(TextDomainError::TranslationNotFound(message)) => assert_eq!(message, "en"),
449            _ => panic!(),
450        };
451    }
452
453    #[test]
454    fn attributes() {
455        let text_domain = TextDomain::new("test");
456        assert_eq!("test".to_owned(), text_domain.domainname);
457        assert!(text_domain.locale.is_none());
458        assert_eq!(LocaleCategory::LcMessages, text_domain.locale_category);
459        assert_eq!(text_domain.codeset, "UTF-8");
460        assert!(text_domain.pre_paths.is_empty());
461        assert!(text_domain.post_paths.is_empty());
462        assert!(!text_domain.skip_system_data_paths);
463
464        let text_domain = text_domain.locale_category(LocaleCategory::LcAll);
465        assert_eq!(LocaleCategory::LcAll, text_domain.locale_category);
466
467        let text_domain = text_domain.codeset("ISO-8859-15");
468        assert_eq!("ISO-8859-15", text_domain.codeset);
469
470        let text_domain = text_domain.prepend("pre");
471        assert!(!text_domain.pre_paths.is_empty());
472
473        let text_domain = text_domain.push("post");
474        assert!(!text_domain.post_paths.is_empty());
475
476        let text_domain = text_domain.skip_system_data_paths();
477        assert!(text_domain.skip_system_data_paths);
478
479        let text_domain = TextDomain::new("test").locale("en_US");
480        assert_eq!(Some("en_US".to_owned()), text_domain.locale);
481
482        // accept locale, but fail to find translation
483        match TextDomain::new("0_0").locale("en_US").init().err() {
484            Some(TextDomainError::TranslationNotFound(message)) => assert_eq!(message, "en"),
485            _ => panic!(),
486        };
487    }
488}