1use std::collections::HashMap;
35
36use crate::request::Request;
37use crate::response::Response;
38
39pub const MAX_LANGUAGE_RANGES: usize = 32;
42
43#[derive(Debug, Clone, Default, PartialEq, Eq)]
47pub struct AcceptLanguage(pub Vec<String>);
48
49impl AcceptLanguage {
50 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 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 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
116fn 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#[derive(Debug, Clone, Default)]
132pub struct Catalog {
133 messages: HashMap<String, String>,
134}
135
136impl Catalog {
137 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 pub fn get(&self, key: &str) -> Option<&str> {
159 self.messages.get(key).map(String::as_str)
160 }
161
162 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
172fn 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#[derive(Debug, Clone)]
210pub struct Catalogs {
211 default: String,
212 catalogs: Vec<(String, Catalog)>,
213}
214
215impl Catalogs {
216 pub fn new(default: &str) -> Catalogs {
219 Catalogs { default: default.to_string(), catalogs: Vec::new() }
220 }
221
222 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 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 pub fn languages(&self) -> Vec<&str> {
254 self.catalogs.iter().map(|(l, _)| l.as_str()).collect()
255 }
256
257 pub fn negotiate(&self, preferences: &AcceptLanguage) -> &str {
259 preferences.negotiate(&self.languages()).unwrap_or(&self.default)
260 }
261
262 pub fn for_request(&self, request: &Request) -> Translator<'_> {
264 self.for_preferences(&AcceptLanguage::from_request(request))
265 }
266
267 pub fn translator(&self, accept_language: &str) -> Translator<'_> {
269 self.for_preferences(&AcceptLanguage::parse(accept_language))
270 }
271
272 pub fn for_preferences(&self, preferences: &AcceptLanguage) -> Translator<'_> {
274 Translator { catalogs: self, locale: self.negotiate(preferences).to_string() }
275 }
276
277 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 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#[derive(Debug, Clone)]
295pub struct Translator<'a> {
296 catalogs: &'a Catalogs,
297 locale: String,
298}
299
300impl Translator<'_> {
301 pub fn locale(&self) -> &str {
303 &self.locale
304 }
305
306 pub fn t(&self, key: &str) -> String {
308 self.t_with(key, &[])
309 }
310
311 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 pub fn plural(&self, key: &str, count: u64) -> String {
324 self.plural_with(key, count, &[])
325 }
326
327 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 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
355fn 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
381pub 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;