Skip to main content

rust_web_server/i18n/
mod.rs

1//! Translations (18.16.0, #33): message catalogs, one per language, and the
2//! language each request asks for.
3//!
4//! ```rust
5//! use rust_web_server::i18n::Catalogs;
6//!
7//! let catalogs = Catalogs::new("en")
8//!     .add("en", r#"
9//!         greeting = "Hello, {name}!"
10//!         items.one = "{count} item"
11//!         items.other = "{count} items"
12//!     "#).unwrap()
13//!     .add("uk", r#"
14//!         greeting = "Привіт, {name}!"
15//!         items.one = "{count} річ"
16//!         items.few = "{count} речі"
17//!         items.many = "{count} речей"
18//!     "#).unwrap();
19//!
20//! // In a handler: catalogs.for_request(&request) reads Accept-Language.
21//! let t = catalogs.translator("uk-UA,uk;q=0.9,en;q=0.5");
22//! assert_eq!("uk", t.locale());
23//! assert_eq!("Привіт, Олена!", t.t_with("greeting", &[("name", "Олена")]));
24//! assert_eq!("3 речі", t.plural("items", 3));
25//! assert_eq!("5 речей", t.plural("items", 5));
26//! ```
27//!
28//! Catalog files are a flat subset of TOML (`key = "value"`, `#` comments,
29//! `\n \t \" \\ \u{…}` escapes), one per language: `Catalogs::from_dir`
30//! reads `<dir>/<language>.toml`. A key missing in the chosen language falls
31//! back to its base language (`en-GB` → `en`), then the default language,
32//! then the key itself.
33
34use std::collections::HashMap;
35
36use crate::request::Request;
37use crate::response::Response;
38
39/// At most this many `Accept-Language` entries are considered (the header
40/// is the client's to make arbitrarily long).
41pub const MAX_LANGUAGE_RANGES: usize = 32;
42
43/// A client's language preferences, from `Accept-Language`: tags in the
44/// order to try them (highest `q` first, then header order), lower-cased.
45/// Ranges with `q=0` are left out; `*` is kept as `"*"`.
46#[derive(Debug, Clone, Default, PartialEq, Eq)]
47pub struct AcceptLanguage(pub Vec<String>);
48
49impl AcceptLanguage {
50    /// Parse an `Accept-Language` value. Never fails: malformed entries are
51    /// skipped.
52    pub fn parse(value: &str) -> AcceptLanguage {
53        let mut ranges: Vec<(u16, usize, String)> = Vec::new();
54        for (index, entry) in value.split(',').take(MAX_LANGUAGE_RANGES).enumerate() {
55            let mut parts = entry.split(';');
56            let tag = parts.next().unwrap_or("").trim();
57            if tag.is_empty() || tag.len() > 35 || !(tag == "*" || tag.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-')) {
58                continue;
59            }
60            let mut q = 1000u16;
61            for parameter in parts {
62                if let Some(value) = parameter.trim().strip_prefix("q=").or_else(|| parameter.trim().strip_prefix("Q=")) {
63                    q = parse_q(value.trim()).unwrap_or(0);
64                }
65            }
66            if q > 0 {
67                ranges.push((q, index, tag.to_ascii_lowercase()));
68            }
69        }
70        ranges.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.cmp(&b.1)));
71        AcceptLanguage(ranges.into_iter().map(|(_, _, tag)| tag).collect())
72    }
73
74    /// The request's preferences (empty without the header).
75    pub fn from_request(request: &Request) -> AcceptLanguage {
76        request.header(crate::header::Header::_ACCEPT_LANGUAGE).map(|h| AcceptLanguage::parse(&h.value)).unwrap_or_default()
77    }
78
79    /// The best of `available` for these preferences (RFC 4647 lookup:
80    /// each range as is, then with subtags removed from the end; then a
81    /// range's language matching an available regional tag — `en` gets
82    /// `en-GB`), or `None`. `*` matches nothing in particular, so the caller's
83    /// default applies.
84    pub fn negotiate<'a>(&self, available: &[&'a str]) -> Option<&'a str> {
85        for range in &self.0 {
86            let mut candidate = range.as_str();
87            loop {
88                if let Some(found) = available.iter().find(|a| a.eq_ignore_ascii_case(candidate)) {
89                    return Some(found);
90                }
91                match candidate.rfind('-') {
92                    Some(cut) => candidate = &candidate[..cut],
93                    None => break,
94                }
95            }
96        }
97        for range in &self.0 {
98            let language = range.split('-').next().unwrap_or("");
99            if language == "*" {
100                continue;
101            }
102            if let Some(found) = available.iter().find(|a| a.split('-').next().is_some_and(|l| l.eq_ignore_ascii_case(language))) {
103                return Some(found);
104            }
105        }
106        None
107    }
108}
109
110impl crate::extract::FromRequest for AcceptLanguage {
111    fn from_request(request: &Request) -> Result<Self, Response> {
112        Ok(AcceptLanguage::from_request(request))
113    }
114}
115
116/// `q` as thousandths (`0.8` → 800); `None` when it isn't a valid qvalue.
117fn parse_q(value: &str) -> Option<u16> {
118    let (whole, fraction) = value.split_once('.').unwrap_or((value, ""));
119    if fraction.len() > 3 || !fraction.bytes().all(|b| b.is_ascii_digit()) {
120        return None;
121    }
122    let digits = format!("{fraction:0<3}");
123    match whole {
124        "0" => digits.parse().ok(),
125        "1" if digits == "000" => Some(1000),
126        _ => None,
127    }
128}
129
130/// One language's messages.
131#[derive(Debug, Clone, Default)]
132pub struct Catalog {
133    messages: HashMap<String, String>,
134}
135
136impl Catalog {
137    /// Parse catalog text (`key = "value"` lines). Errors name the line.
138    pub fn parse(text: &str) -> Result<Catalog, String> {
139        let mut messages = HashMap::new();
140        for (number, line) in text.lines().enumerate() {
141            let line = line.trim();
142            if line.is_empty() || line.starts_with('#') {
143                continue;
144            }
145            let at = |what: &str| format!("line {}: {what}", number + 1);
146            let (key, value) = line.split_once('=').ok_or_else(|| at("expected key = \"value\""))?;
147            let key = key.trim().trim_matches('"').trim();
148            if key.is_empty() || !key.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-' | b'.')) {
149                return Err(at(&format!("{key:?} isn't a key (letters, digits, _ - .)")));
150            }
151            let value = parse_string(value.trim()).map_err(|e| at(&e))?;
152            messages.insert(key.to_string(), value);
153        }
154        Ok(Catalog { messages })
155    }
156
157    /// The message for `key`, as written.
158    pub fn get(&self, key: &str) -> Option<&str> {
159        self.messages.get(key).map(String::as_str)
160    }
161
162    /// How many messages it has.
163    pub fn len(&self) -> usize {
164        self.messages.len()
165    }
166
167    pub fn is_empty(&self) -> bool {
168        self.messages.is_empty()
169    }
170}
171
172/// A quoted TOML basic string (`"…"`) or literal string (`'…'`), with an
173/// optional `# comment` after it.
174fn parse_string(raw: &str) -> Result<String, String> {
175    let mut chars = raw.chars();
176    let quote = chars.next().filter(|c| *c == '"' || *c == '\'').ok_or("the value must be quoted")?;
177    let mut out = String::new();
178    loop {
179        let c = chars.next().ok_or("the value has no closing quote")?;
180        if c == quote {
181            break;
182        }
183        if c != '\\' || quote == '\'' {
184            out.push(c);
185            continue;
186        }
187        match chars.next().ok_or("the value ends in a backslash")? {
188            'n' => out.push('\n'),
189            't' => out.push('\t'),
190            'r' => out.push('\r'),
191            '"' => out.push('"'),
192            '\\' => out.push('\\'),
193            'u' => {
194                let hex: String = chars.by_ref().take(4).collect();
195                let code = u32::from_str_radix(&hex, 16).ok().and_then(char::from_u32).ok_or("bad \\u escape")?;
196                out.push(code);
197            }
198            other => return Err(format!("unknown escape \\{other}")),
199        }
200    }
201    let rest = chars.as_str().trim();
202    if !rest.is_empty() && !rest.starts_with('#') {
203        return Err(format!("unexpected {rest:?} after the value"));
204    }
205    Ok(out)
206}
207
208/// Every language's catalog and the default language.
209#[derive(Debug, Clone)]
210pub struct Catalogs {
211    default: String,
212    catalogs: Vec<(String, Catalog)>,
213}
214
215impl Catalogs {
216    /// No catalogs yet; `default` is the language used when a request asks
217    /// for none of them (and for keys a language lacks).
218    pub fn new(default: &str) -> Catalogs {
219        Catalogs { default: default.to_string(), catalogs: Vec::new() }
220    }
221
222    /// Add (or replace) `language`'s catalog from its text.
223    pub fn add(mut self, language: &str, text: &str) -> Result<Catalogs, String> {
224        let catalog = Catalog::parse(text).map_err(|e| format!("{language}: {e}"))?;
225        self.catalogs.retain(|(l, _)| !l.eq_ignore_ascii_case(language));
226        self.catalogs.push((language.to_string(), catalog));
227        Ok(self)
228    }
229
230    /// Every `<language>.toml` in `dir`; `default` as in [`Catalogs::new`].
231    pub fn from_dir(dir: impl AsRef<std::path::Path>, default: &str) -> Result<Catalogs, String> {
232        let dir = dir.as_ref();
233        let mut entries: Vec<_> = std::fs::read_dir(dir)
234            .map_err(|e| format!("can't read {}: {e}", dir.display()))?
235            .filter_map(Result::ok)
236            .map(|e| e.path())
237            .filter(|p| p.extension().is_some_and(|x| x == "toml"))
238            .collect();
239        entries.sort();
240        let mut catalogs = Catalogs::new(default);
241        for path in entries {
242            let Some(language) = path.file_stem().and_then(|s| s.to_str()) else { continue };
243            let text = std::fs::read_to_string(&path).map_err(|e| format!("can't read {}: {e}", path.display()))?;
244            catalogs = catalogs.add(language, &text).map_err(|e| format!("{}: {e}", path.display()))?;
245        }
246        if catalogs.catalogs.is_empty() {
247            return Err(format!("no <language>.toml catalogs in {}", dir.display()));
248        }
249        Ok(catalogs)
250    }
251
252    /// The languages with a catalog.
253    pub fn languages(&self) -> Vec<&str> {
254        self.catalogs.iter().map(|(l, _)| l.as_str()).collect()
255    }
256
257    /// The language to answer in for these preferences.
258    pub fn negotiate(&self, preferences: &AcceptLanguage) -> &str {
259        preferences.negotiate(&self.languages()).unwrap_or(&self.default)
260    }
261
262    /// A translator for the request's `Accept-Language`.
263    pub fn for_request(&self, request: &Request) -> Translator<'_> {
264        self.for_preferences(&AcceptLanguage::from_request(request))
265    }
266
267    /// A translator for an `Accept-Language` value.
268    pub fn translator(&self, accept_language: &str) -> Translator<'_> {
269        self.for_preferences(&AcceptLanguage::parse(accept_language))
270    }
271
272    /// A translator for parsed preferences.
273    pub fn for_preferences(&self, preferences: &AcceptLanguage) -> Translator<'_> {
274        Translator { catalogs: self, locale: self.negotiate(preferences).to_string() }
275    }
276
277    /// A translator for one language (a user's saved choice, say).
278    pub fn for_language(&self, language: &str) -> Translator<'_> {
279        Translator { catalogs: self, locale: language.to_string() }
280    }
281
282    fn catalog(&self, language: &str) -> Option<&Catalog> {
283        self.catalogs.iter().find(|(l, _)| l.eq_ignore_ascii_case(language)).map(|(_, c)| c)
284    }
285
286    /// `key` in `language`, then its base language, then the default.
287    fn lookup(&self, language: &str, key: &str) -> Option<&str> {
288        let base = language.split('-').next().unwrap_or(language);
289        [language, base, self.default.as_str()].into_iter().find_map(|l| self.catalog(l)?.get(key))
290    }
291}
292
293/// Messages in one language.
294#[derive(Debug, Clone)]
295pub struct Translator<'a> {
296    catalogs: &'a Catalogs,
297    locale: String,
298}
299
300impl Translator<'_> {
301    /// The language chosen (for `Content-Language`, `<html lang>`).
302    pub fn locale(&self) -> &str {
303        &self.locale
304    }
305
306    /// The message for `key`; the key itself when no catalog has it.
307    pub fn t(&self, key: &str) -> String {
308        self.t_with(key, &[])
309    }
310
311    /// The message for `key` with `{name}` placeholders filled from `args`
312    /// (`{{` and `}}` are literal braces; unknown names stay as written).
313    pub fn t_with(&self, key: &str, args: &[(&str, &str)]) -> String {
314        match self.catalogs.lookup(&self.locale, key) {
315            Some(message) => format_message(message, args),
316            None => key.to_string(),
317        }
318    }
319
320    /// `key.<category>` for `count` in this language (`one`, `few`, `many`,
321    /// `other`; see [`plural_category`]), falling back to `key.other`, with
322    /// `{count}` and `args` filled in.
323    pub fn plural(&self, key: &str, count: u64) -> String {
324        self.plural_with(key, count, &[])
325    }
326
327    /// [`Translator::plural`] with more placeholders.
328    pub fn plural_with(&self, key: &str, count: u64, args: &[(&str, &str)]) -> String {
329        let count_text = count.to_string();
330        let mut all: Vec<(&str, &str)> = vec![("count", &count_text)];
331        all.extend_from_slice(args);
332        let category = plural_category(&self.locale, count);
333        let chosen = format!("{key}.{category}");
334        match self.catalogs.lookup(&self.locale, &chosen).or_else(|| self.catalogs.lookup(&self.locale, &format!("{key}.other"))) {
335            Some(message) => format_message(message, &all),
336            None => chosen,
337        }
338    }
339
340    /// Mark `response` as being in this language: `Content-Language` and
341    /// `Vary: Accept-Language` (added to an existing `Vary`).
342    pub fn localize(&self, mut response: Response) -> Response {
343        use crate::header::Header;
344        response.headers.retain(|h| !h.name.eq_ignore_ascii_case(Header::_CONTENT_LANGUAGE));
345        response.headers.push(Header::new(Header::_CONTENT_LANGUAGE, self.locale.clone()));
346        match response.headers.iter_mut().find(|h| h.name.eq_ignore_ascii_case(Header::_VARY)) {
347            Some(vary) if vary.value.split(',').any(|v| v.trim().eq_ignore_ascii_case("accept-language")) => {}
348            Some(vary) => vary.value = format!("{}, Accept-Language", vary.value).into(),
349            None => response.headers.push(Header::new(Header::_VARY, "Accept-Language".to_string())),
350        }
351        response
352    }
353}
354
355/// Fill `{name}` placeholders.
356fn format_message(message: &str, args: &[(&str, &str)]) -> String {
357    let mut out = String::with_capacity(message.len());
358    let mut rest = message;
359    while let Some(i) = rest.find(['{', '}']) {
360        out.push_str(&rest[..i]);
361        let tail = &rest[i..];
362        if tail.starts_with("{{") || tail.starts_with("}}") {
363            out.push_str(&tail[..1]);
364            rest = &tail[2..];
365        } else if let Some(end) = tail.strip_prefix('{').and_then(|t| t.find('}')) {
366            let name = &tail[1..1 + end];
367            match args.iter().find(|(n, _)| *n == name) {
368                Some((_, value)) => out.push_str(value),
369                None => out.push_str(&tail[..end + 2]),
370            }
371            rest = &tail[end + 2..];
372        } else {
373            out.push_str(&tail[..1]);
374            rest = &tail[1..];
375        }
376    }
377    out.push_str(rest);
378    out
379}
380
381/// The CLDR plural category of a whole `count` in `language`: `"one"`,
382/// `"few"`, `"many"` or `"other"`. Covered: East Slavic and Polish
383/// (`uk`, `ru`, `be`, `pl`: one/few/many), Czech and Slovak (one/few),
384/// French and Brazilian Portuguese (0 and 1 are `one`), languages without
385/// plural forms (`ja`, `zh`, `ko`, `vi`, `th`, `id`, `ms`, `tr`… : other);
386/// every other language uses English's one/other.
387pub fn plural_category(language: &str, count: u64) -> &'static str {
388    let lower = language.to_ascii_lowercase();
389    let base = lower.split('-').next().unwrap_or("");
390    let (last, last_two) = (count % 10, count % 100);
391    match base {
392        "uk" | "ru" | "be" => {
393            if last == 1 && last_two != 11 {
394                "one"
395            } else if (2..=4).contains(&last) && !(12..=14).contains(&last_two) {
396                "few"
397            } else {
398                "many"
399            }
400        }
401        "pl" => {
402            if count == 1 {
403                "one"
404            } else if (2..=4).contains(&last) && !(12..=14).contains(&last_two) {
405                "few"
406            } else {
407                "many"
408            }
409        }
410        "cs" | "sk" => match count {
411            1 => "one",
412            2..=4 => "few",
413            _ => "other",
414        },
415        "fr" => {
416            if count <= 1 {
417                "one"
418            } else {
419                "other"
420            }
421        }
422        "pt" if lower != "pt-pt" => {
423            if count <= 1 {
424                "one"
425            } else {
426                "other"
427            }
428        }
429        "ja" | "zh" | "ko" | "vi" | "th" | "id" | "ms" | "lo" | "my" | "km" | "tr" => "other",
430        _ => {
431            if count == 1 {
432                "one"
433            } else {
434                "other"
435            }
436        }
437    }
438}
439
440#[cfg(test)]
441mod tests;