Skip to main content

cranpose_localization/
locale.rs

1use std::{fmt, str::FromStr};
2
3use unic_langid::{CharacterDirection, LanguageIdentifier};
4
5use crate::LocalizationError;
6
7/// A visual stress test applied after message formatting.
8#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
9pub enum PreviewMode {
10    /// Render the selected language unchanged.
11    #[default]
12    None,
13    /// Add accents and length to expose clipped labels.
14    Expanded,
15    /// Isolate text in a right-to-left paragraph and mirror layout defaults.
16    Rtl,
17}
18
19/// A validated language tag and optional localization preview.
20#[derive(Clone, Debug, PartialEq, Eq)]
21pub struct Locale {
22    pub(crate) language: LanguageIdentifier,
23    pub(crate) preview: PreviewMode,
24}
25
26impl Locale {
27    /// Parses a language tag such as `sr-Latn` or `ar-EG`.
28    /// Valid BCP 47 extensions and private-use subtags are ignored for catalog selection.
29    pub fn parse(tag: &str) -> Result<Self, LocalizationError> {
30        let invalid = || LocalizationError::InvalidLocale(tag.to_owned());
31        let language = language_identifier(tag)
32            .ok_or_else(invalid)?
33            .parse()
34            .map_err(|_| invalid())?;
35        Ok(Self {
36            language,
37            preview: PreviewMode::None,
38        })
39    }
40
41    /// Uses this language with the selected visual stress test.
42    pub fn with_preview(mut self, preview: PreviewMode) -> Self {
43        self.preview = preview;
44        self
45    }
46
47    /// Whether this locale's script reads from right to left.
48    pub fn is_rtl(&self) -> bool {
49        self.preview == PreviewMode::Rtl
50            || self.language.character_direction() == CharacterDirection::RTL
51    }
52}
53
54fn language_identifier(tag: &str) -> Option<&str> {
55    let mut end = tag.len();
56    let mut offset = 0;
57    let mut extension = false;
58    let mut private = false;
59    let mut needs_value = false;
60    let mut seen = 0u64;
61    for part in tag.split('-') {
62        if !extension && offset != 0 && part.len() == 1 {
63            end = offset - 1;
64            extension = true;
65        }
66        if extension {
67            if part.len() == 1 && !private {
68                let byte = part.as_bytes()[0].to_ascii_lowercase();
69                let index = match byte {
70                    b'0'..=b'9' => byte - b'0',
71                    b'a'..=b'z' => byte - b'a' + 10,
72                    _ => return None,
73                };
74                let flag = 1u64 << index;
75                if needs_value || seen & flag != 0 {
76                    return None;
77                }
78                seen |= flag;
79                private = byte == b'x';
80                needs_value = true;
81            } else {
82                let minimum = if private { 1 } else { 2 };
83                if !(minimum..=8).contains(&part.len())
84                    || !part.bytes().all(|byte| byte.is_ascii_alphanumeric())
85                {
86                    return None;
87                }
88                needs_value = false;
89            }
90        }
91        offset += part.len() + 1;
92    }
93    (!needs_value).then_some(&tag[..end])
94}
95
96impl FromStr for Locale {
97    type Err = LocalizationError;
98
99    fn from_str(tag: &str) -> Result<Self, Self::Err> {
100        Self::parse(tag)
101    }
102}
103
104impl fmt::Display for Locale {
105    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
106        self.language.fmt(formatter)
107    }
108}
109
110pub(crate) fn preview_text(text: String, preview: PreviewMode) -> String {
111    match preview {
112        PreviewMode::None => text,
113        PreviewMode::Rtl => format!("\u{2067}{text}\u{2069}"),
114        PreviewMode::Expanded => {
115            let mut output = String::with_capacity(text.len() * 2 + 4);
116            output.push('[');
117            for ch in text.chars() {
118                output.push(match ch {
119                    'a' => 'á',
120                    'e' => 'ë',
121                    'i' => 'ï',
122                    'o' => 'ö',
123                    'u' => 'ü',
124                    'A' => 'Á',
125                    'E' => 'Ë',
126                    'I' => 'Ï',
127                    'O' => 'Ö',
128                    'U' => 'Ü',
129                    other => other,
130                });
131                if ch.is_ascii_alphabetic() && "aeiouAEIOU".contains(ch) {
132                    output.push('~');
133                }
134            }
135            output.push(']');
136            output
137        }
138    }
139}