| 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| { |
| 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 | |
| 395 | fn get_system_data_paths() -> String { |
| 396 | static DEFAULT: &str = "/usr/local/share/:/usr/share/" ; |
| 397 | |
| 398 | if let Ok(dirs: String) = env::var(key:"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 | |
| 409 | impl 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)] |
| 437 | mod 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 | } |
| 489 | |