Skip to main content

locale_types/
string.rs

1/*!
2The `LocaleString` type provides the a structure for locale identifier strings.
3
4## Standards
5
6> On POSIX platforms such as Unix, Linux and others, locale identifiers are defined by
7> ISO/IEC 15897, which is similar to the BCP 47 definition of language tags, but the
8> locale variant modifier is defined differently, and the character set is included as
9> a part of the identifier.
10
11Locale identifiers are defined in this format: `[language[_territory][.codeset][@modifier]]`.
12For example, Australian English using the UTF-8 encoding is `en_AU.UTF-8`.
13
14* `language` = [ISO 639-1](https://en.wikipedia.org/wiki/ISO_639-1) 2-character language
15  codes.
16* `territory` = [ISO 3166-1](https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2) 2-character
17  country codes.
18* `codeset` = an undefined string value, `[a-zA-Z0-9_\-]+`.
19  * For example, [IEC 8859](https://en.wikipedia.org/wiki/ISO/IEC_8859) parts 1 to 16 are
20    usually specified as `ISO8859-1` and so on.
21  * should be taken from the values in the IANA
22    [character sets](https://www.iana.org/assignments/character-sets/character-sets.xhtml)
23    list.
24* `modifier` = a semi-colon separated list of _identifiers_, or _name '=' value_ pairs.
25  * Sometimes this is used to indicate the language script in use, as such values from
26    [ISO 15924](http://unicode.org/iso15924/iso15924-codes.html) should be used.
27
28See also:
29
30* [Wikipedia _Locale_](https://en.wikipedia.org/wiki/Locale_(computer_software))
31* [GNU C Library - _Locale-Names_](https://www.gnu.org/software/libc/manual/html_node/Locale-Names.html)
32* [Apple - _NSLocale_](https://developer.apple.com/documentation/foundation/nslocale) and
33  [_localeIdentifier_](https://developer.apple.com/documentation/foundation/nslocale/1416263-localeidentifier)
34* [Microsoft C Runtime - _Locale names, Languages, and Country/Region strings_](https://docs.microsoft.com/en-us/cpp/c-runtime-library/locale-names-languages-and-country-region-strings?view=vs-2019)
35* [Microsoft Windows - _Locale Names_](https://docs.microsoft.com/en-us/windows/win32/intl/locale-names)
36* [IETF _Tags for Identifying Languages_](https://tools.ietf.org/html/bcp47)
37* [W3C _Language Tags and Locale Identifiers for the World Wide Web_](https://www.w3.org/TR/ltli/)
38* [ISO _Procedures for the registration of cultural elements_](https://www.iso.org/standard/50707.html)
39
40*/
41use std::collections::HashMap;
42use std::fmt;
43use std::fmt::Display;
44use std::str::FromStr;
45
46use regex::Regex;
47
48use crate::id::LocaleIdentifier;
49use crate::{LocaleError, LocaleResult};
50
51// ------------------------------------------------------------------------------------------------
52// Public Types
53// ------------------------------------------------------------------------------------------------
54
55/// A `LocaleString` is a representation of the POSIX notion of a Locale
56/// identifier, used in operating system calls and environment variables.
57/// It implements the `LocaleIdentifier` trait.
58#[derive(Debug, PartialEq)]
59pub struct LocaleString {
60    language_code: String,
61    territory: Option<String>,
62    code_set: Option<String>,
63    modifier: Option<String>,
64}
65
66/// Errors possibly returned from `from_str()`.
67#[derive(Debug, PartialEq)]
68pub enum ParseError {
69    /// The empty string is not a valid identifier.
70    EmptyString,
71    /// The value "POSIX" or "C" is not a locale identifier in this context.
72    PosixUnsupported,
73    /// The string failed to match the internal regular expression(s).
74    RegexFailure,
75    /// The provided language code was not valid.
76    InvalidLanguageCode,
77    /// The provided territory code was not valid.
78    InvalidTerritoryCode,
79    /// The provided code set name was not valid.
80    InvalidCodeSet,
81    /// The provided modifier string was not valid.
82    InvalidModifier,
83    /// The provided file system path was not valid.
84    InvalidPath,
85}
86
87// ------------------------------------------------------------------------------------------------
88// Implementations - LocaleString
89// ------------------------------------------------------------------------------------------------
90
91const SEP_TERRITORY: char = '_';
92const SEP_CODE_SET: char = '.';
93const SEP_MODIFIER: char = '@';
94
95impl LocaleIdentifier for LocaleString {
96    fn new(language_code: String) -> LocaleResult<Self> {
97        if language_code.len() != 2 || !language_code.chars().all(|c| c.is_lowercase()) {
98            return Err(LocaleError::InvalidLanguageCode);
99        };
100
101        Ok(LocaleString {
102            language_code,
103            territory: None,
104            code_set: None,
105            modifier: None,
106        })
107    }
108
109    fn with_language(&self, language_code: String) -> LocaleResult<Self> {
110        if language_code.len() != 2 || !language_code.chars().all(|c| c.is_lowercase()) {
111            return Err(LocaleError::InvalidLanguageCode);
112        };
113
114        Ok(LocaleString {
115            language_code,
116            territory: self.territory.clone(),
117            code_set: self.code_set.clone(),
118            modifier: self.modifier.clone(),
119        })
120    }
121
122    fn with_territory(&self, territory: String) -> LocaleResult<Self> {
123        if territory.len() < 2
124            || territory.len() > 2
125            || !territory.chars().all(|c| c.is_uppercase())
126        {
127            return Err(LocaleError::InvalidTerritoryCode);
128        };
129
130        Ok(LocaleString {
131            language_code: self.language_code.clone(),
132            territory: Some(territory),
133            code_set: self.code_set.clone(),
134            modifier: self.modifier.clone(),
135        })
136    }
137
138    fn with_code_set(&self, code_set: String) -> LocaleResult<Self> {
139        if code_set.chars().all(|c| c.is_whitespace()) {
140            return Err(LocaleError::InvalidCodeSet);
141        };
142        Ok(LocaleString {
143            language_code: self.language_code.clone(),
144            territory: self.territory.clone(),
145            code_set: Some(code_set),
146            modifier: self.modifier.clone(),
147        })
148    }
149
150    fn with_modifier(&self, modifier: String) -> LocaleResult<Self> {
151        Ok(LocaleString {
152            language_code: self.language_code.clone(),
153            territory: self.territory.clone(),
154            code_set: self.code_set.clone(),
155            modifier: Some(modifier),
156        })
157    }
158
159    fn with_modifiers<K, V>(&self, modifiers: HashMap<K, V>) -> LocaleResult<Self>
160    where
161        K: Display,
162        V: Display,
163    {
164        let modifier_strings: Vec<String> = modifiers
165            .iter()
166            .map(|(key, value)| format!("{}={}", key, value))
167            .collect();
168
169        Ok(LocaleString {
170            language_code: self.language_code.clone(),
171            territory: self.territory.clone(),
172            code_set: self.code_set.clone(),
173            modifier: Some(modifier_strings.join(";")),
174        })
175    }
176
177    fn language_code(&self) -> String {
178        self.language_code.clone()
179    }
180
181    fn territory(&self) -> Option<String> {
182        self.territory.clone()
183    }
184
185    fn code_set(&self) -> Option<String> {
186        self.code_set.clone()
187    }
188
189    fn modifier(&self) -> Option<String> {
190        self.modifier.clone()
191    }
192}
193
194impl Display for LocaleString {
195    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
196        write!(
197            f,
198            "{}",
199            [
200                self.language_code.clone(),
201                match &self.territory {
202                    Some(v) => format!("{}{}", SEP_TERRITORY, v),
203                    None => "".to_string(),
204                },
205                match &self.code_set {
206                    Some(v) => format!("{}{}", SEP_CODE_SET, v),
207                    None => "".to_string(),
208                },
209                match &self.modifier {
210                    Some(v) => format!("{}{}", SEP_MODIFIER, v),
211                    None => "".to_string(),
212                },
213            ]
214            .join("")
215        )
216    }
217}
218
219impl FromStr for LocaleString {
220    type Err = ParseError;
221
222    fn from_str(s: &str) -> Result<Self, Self::Err> {
223        lazy_static! {
224            static ref RE: Regex =
225                Regex::new(r"^([a-z][a-z]+)(_[A-Z][A-Z]+)?(\.[A-Z][a-zA-Z0-9\-_]+)?(@\w+)?$")
226                    .unwrap();
227        }
228
229        if s.is_empty() {
230            return Err(ParseError::EmptyString);
231        }
232
233        if s == "C" || s == "POSIX" {
234            return Err(ParseError::PosixUnsupported);
235        }
236
237        match RE.captures(s) {
238            None => Err(ParseError::RegexFailure),
239            Some(groups) => {
240                let mut locale =
241                    LocaleString::new(groups.get(1).unwrap().as_str().to_string()).unwrap();
242                if let Some(group_str) = groups.get(2) {
243                    locale = locale
244                        .with_territory(group_str.as_str()[1..].to_string())
245                        .unwrap();
246                }
247                if let Some(group_str) = groups.get(3) {
248                    locale = locale
249                        .with_code_set(group_str.as_str()[1..].to_string())
250                        .unwrap();
251                }
252                if let Some(group_str) = groups.get(4) {
253                    locale = locale
254                        .with_modifier(group_str.as_str()[1..].to_string())
255                        .unwrap();
256                }
257                Ok(locale)
258            }
259        }
260    }
261}
262
263// ------------------------------------------------------------------------------------------------
264// Unit Tests
265// ------------------------------------------------------------------------------------------------
266
267#[cfg(test)]
268mod tests {
269    use std::collections::HashMap;
270    use std::str::FromStr;
271
272    use crate::{LocaleError, LocaleIdentifier, LocaleString};
273
274    // --------------------------------------------------------------------------------------------
275    #[test]
276    fn test_bad_constructor_length() {
277        assert_eq!(
278            LocaleString::new("english".to_string()),
279            Err(LocaleError::InvalidLanguageCode)
280        );
281    }
282
283    #[test]
284    fn test_bad_constructor_case() {
285        assert_eq!(
286            LocaleString::new("EN".to_string()),
287            Err(LocaleError::InvalidLanguageCode)
288        );
289    }
290
291    #[test]
292    fn test_bad_territory_length() {
293        assert_eq!(
294            LocaleString::new("en".to_string())
295                .unwrap()
296                .with_territory("USA".to_string()),
297            Err(LocaleError::InvalidTerritoryCode)
298        );
299    }
300
301    #[test]
302    fn test_bad_country_case() {
303        assert_eq!(
304            LocaleString::new("en".to_string())
305                .unwrap()
306                .with_territory("us".to_string()),
307            Err(LocaleError::InvalidTerritoryCode)
308        );
309    }
310
311    // --------------------------------------------------------------------------------------------
312    #[test]
313    fn test_constructor() {
314        let locale = LocaleString::new("en".to_string()).unwrap();
315        assert_eq!(locale.language_code(), "en".to_string());
316        assert_eq!(locale.territory(), None);
317        assert_eq!(locale.modifier(), None);
318    }
319
320    #[test]
321    fn test_with_language() {
322        let locale = LocaleString::new("en".to_string()).unwrap();
323        assert_eq!(
324            locale
325                .with_language("fr".to_string())
326                .unwrap()
327                .language_code(),
328            "fr".to_string()
329        );
330    }
331
332    #[test]
333    fn test_with_country() {
334        let locale = LocaleString::new("en".to_string()).unwrap();
335        assert_eq!(
336            locale.with_territory("UK".to_string()).unwrap().territory(),
337            Some("UK".to_string())
338        );
339    }
340
341    #[test]
342    fn test_with_code_set() {
343        let locale = LocaleString::new("en".to_string()).unwrap();
344        assert_eq!(
345            locale
346                .with_code_set("UTF-8".to_string())
347                .unwrap()
348                .code_set(),
349            Some("UTF-8".to_string())
350        );
351    }
352
353    #[test]
354    fn test_with_modifier() {
355        let locale = LocaleString::new("en".to_string()).unwrap();
356        assert_eq!(
357            locale
358                .with_modifier("collation=pinyin;currency=CNY".to_string())
359                .unwrap()
360                .modifier(),
361            Some("collation=pinyin;currency=CNY".to_string())
362        );
363    }
364
365    #[test]
366    fn test_with_modifiers() {
367        let locale = LocaleString::new("en".to_string()).unwrap();
368        let modifiers: HashMap<&str, &str> = [("collation", "pinyin"), ("currency", "CNY")]
369            .iter()
370            .cloned()
371            .collect();
372        assert!(locale
373            .with_modifiers(modifiers)
374            .unwrap()
375            .modifier()
376            .unwrap()
377            .contains("collation=pinyin"));
378        //        assert!(
379        //            locale.with_modifiers(modifiers).get_modifier().unwrap().contains("currency=CNY")
380        //        );
381    }
382
383    // --------------------------------------------------------------------------------------------
384    #[test]
385    fn test_to_string() {
386        let locale = LocaleString::new("en".to_string())
387            .unwrap()
388            .with_territory("US".to_string())
389            .unwrap()
390            .with_code_set("UTF-8".to_string())
391            .unwrap()
392            .with_modifier("collation=pinyin;currency=CNY".to_string())
393            .unwrap();
394        assert_eq!(
395            locale.to_string(),
396            "en_US.UTF-8@collation=pinyin;currency=CNY".to_string()
397        );
398    }
399
400    // --------------------------------------------------------------------------------------------
401    #[test]
402    fn test_from_str_1() {
403        match LocaleString::from_str("en") {
404            Ok(locale) => assert_eq!(locale.language_code(), "en"),
405            _ => panic!("LocaleString::from_str failure"),
406        }
407    }
408
409    #[test]
410    fn test_from_str_2() {
411        match LocaleString::from_str("en_US") {
412            Ok(locale) => {
413                assert_eq!(locale.language_code(), "en");
414                assert_eq!(locale.territory(), Some("US".to_string()));
415            }
416            _ => panic!("LocaleString::from_str failure"),
417        }
418    }
419
420    #[test]
421    fn test_from_str_3() {
422        match LocaleString::from_str("en_US.UTF-8") {
423            Ok(locale) => {
424                assert_eq!(locale.language_code(), "en");
425                assert_eq!(locale.territory(), Some("US".to_string()));
426                assert_eq!(locale.code_set(), Some("UTF-8".to_string()));
427            }
428            _ => panic!("LocaleString::from_str failure"),
429        }
430    }
431
432    #[test]
433    fn test_from_str_4() {
434        match LocaleString::from_str("en_US.UTF-8@Latn") {
435            Ok(locale) => {
436                assert_eq!(locale.language_code(), "en");
437                assert_eq!(locale.territory(), Some("US".to_string()));
438                assert_eq!(locale.code_set(), Some("UTF-8".to_string()));
439                assert_eq!(locale.modifier(), Some("Latn".to_string()));
440            }
441            _ => panic!("LocaleString::from_str failure"),
442        }
443    }
444}