use std::collections::HashMap;
use crate::request::Request;
use crate::response::Response;
pub const MAX_LANGUAGE_RANGES: usize = 32;
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct AcceptLanguage(pub Vec<String>);
impl AcceptLanguage {
pub fn parse(value: &str) -> AcceptLanguage {
let mut ranges: Vec<(u16, usize, String)> = Vec::new();
for (index, entry) in value.split(',').take(MAX_LANGUAGE_RANGES).enumerate() {
let mut parts = entry.split(';');
let tag = parts.next().unwrap_or("").trim();
if tag.is_empty() || tag.len() > 35 || !(tag == "*" || tag.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-')) {
continue;
}
let mut q = 1000u16;
for parameter in parts {
if let Some(value) = parameter.trim().strip_prefix("q=").or_else(|| parameter.trim().strip_prefix("Q=")) {
q = parse_q(value.trim()).unwrap_or(0);
}
}
if q > 0 {
ranges.push((q, index, tag.to_ascii_lowercase()));
}
}
ranges.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.cmp(&b.1)));
AcceptLanguage(ranges.into_iter().map(|(_, _, tag)| tag).collect())
}
pub fn from_request(request: &Request) -> AcceptLanguage {
request.header(crate::header::Header::_ACCEPT_LANGUAGE).map(|h| AcceptLanguage::parse(&h.value)).unwrap_or_default()
}
pub fn negotiate<'a>(&self, available: &[&'a str]) -> Option<&'a str> {
for range in &self.0 {
let mut candidate = range.as_str();
loop {
if let Some(found) = available.iter().find(|a| a.eq_ignore_ascii_case(candidate)) {
return Some(found);
}
match candidate.rfind('-') {
Some(cut) => candidate = &candidate[..cut],
None => break,
}
}
}
for range in &self.0 {
let language = range.split('-').next().unwrap_or("");
if language == "*" {
continue;
}
if let Some(found) = available.iter().find(|a| a.split('-').next().is_some_and(|l| l.eq_ignore_ascii_case(language))) {
return Some(found);
}
}
None
}
}
impl crate::extract::FromRequest for AcceptLanguage {
fn from_request(request: &Request) -> Result<Self, Response> {
Ok(AcceptLanguage::from_request(request))
}
}
fn parse_q(value: &str) -> Option<u16> {
let (whole, fraction) = value.split_once('.').unwrap_or((value, ""));
if fraction.len() > 3 || !fraction.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
let digits = format!("{fraction:0<3}");
match whole {
"0" => digits.parse().ok(),
"1" if digits == "000" => Some(1000),
_ => None,
}
}
#[derive(Debug, Clone, Default)]
pub struct Catalog {
messages: HashMap<String, String>,
}
impl Catalog {
pub fn parse(text: &str) -> Result<Catalog, String> {
let mut messages = HashMap::new();
for (number, line) in text.lines().enumerate() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let at = |what: &str| format!("line {}: {what}", number + 1);
let (key, value) = line.split_once('=').ok_or_else(|| at("expected key = \"value\""))?;
let key = key.trim().trim_matches('"').trim();
if key.is_empty() || !key.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-' | b'.')) {
return Err(at(&format!("{key:?} isn't a key (letters, digits, _ - .)")));
}
let value = parse_string(value.trim()).map_err(|e| at(&e))?;
messages.insert(key.to_string(), value);
}
Ok(Catalog { messages })
}
pub fn get(&self, key: &str) -> Option<&str> {
self.messages.get(key).map(String::as_str)
}
pub fn len(&self) -> usize {
self.messages.len()
}
pub fn is_empty(&self) -> bool {
self.messages.is_empty()
}
}
fn parse_string(raw: &str) -> Result<String, String> {
let mut chars = raw.chars();
let quote = chars.next().filter(|c| *c == '"' || *c == '\'').ok_or("the value must be quoted")?;
let mut out = String::new();
loop {
let c = chars.next().ok_or("the value has no closing quote")?;
if c == quote {
break;
}
if c != '\\' || quote == '\'' {
out.push(c);
continue;
}
match chars.next().ok_or("the value ends in a backslash")? {
'n' => out.push('\n'),
't' => out.push('\t'),
'r' => out.push('\r'),
'"' => out.push('"'),
'\\' => out.push('\\'),
'u' => {
let hex: String = chars.by_ref().take(4).collect();
let code = u32::from_str_radix(&hex, 16).ok().and_then(char::from_u32).ok_or("bad \\u escape")?;
out.push(code);
}
other => return Err(format!("unknown escape \\{other}")),
}
}
let rest = chars.as_str().trim();
if !rest.is_empty() && !rest.starts_with('#') {
return Err(format!("unexpected {rest:?} after the value"));
}
Ok(out)
}
#[derive(Debug, Clone)]
pub struct Catalogs {
default: String,
catalogs: Vec<(String, Catalog)>,
}
impl Catalogs {
pub fn new(default: &str) -> Catalogs {
Catalogs { default: default.to_string(), catalogs: Vec::new() }
}
pub fn add(mut self, language: &str, text: &str) -> Result<Catalogs, String> {
let catalog = Catalog::parse(text).map_err(|e| format!("{language}: {e}"))?;
self.catalogs.retain(|(l, _)| !l.eq_ignore_ascii_case(language));
self.catalogs.push((language.to_string(), catalog));
Ok(self)
}
pub fn from_dir(dir: impl AsRef<std::path::Path>, default: &str) -> Result<Catalogs, String> {
let dir = dir.as_ref();
let mut entries: Vec<_> = std::fs::read_dir(dir)
.map_err(|e| format!("can't read {}: {e}", dir.display()))?
.filter_map(Result::ok)
.map(|e| e.path())
.filter(|p| p.extension().is_some_and(|x| x == "toml"))
.collect();
entries.sort();
let mut catalogs = Catalogs::new(default);
for path in entries {
let Some(language) = path.file_stem().and_then(|s| s.to_str()) else { continue };
let text = std::fs::read_to_string(&path).map_err(|e| format!("can't read {}: {e}", path.display()))?;
catalogs = catalogs.add(language, &text).map_err(|e| format!("{}: {e}", path.display()))?;
}
if catalogs.catalogs.is_empty() {
return Err(format!("no <language>.toml catalogs in {}", dir.display()));
}
Ok(catalogs)
}
pub fn languages(&self) -> Vec<&str> {
self.catalogs.iter().map(|(l, _)| l.as_str()).collect()
}
pub fn negotiate(&self, preferences: &AcceptLanguage) -> &str {
preferences.negotiate(&self.languages()).unwrap_or(&self.default)
}
pub fn for_request(&self, request: &Request) -> Translator<'_> {
self.for_preferences(&AcceptLanguage::from_request(request))
}
pub fn translator(&self, accept_language: &str) -> Translator<'_> {
self.for_preferences(&AcceptLanguage::parse(accept_language))
}
pub fn for_preferences(&self, preferences: &AcceptLanguage) -> Translator<'_> {
Translator { catalogs: self, locale: self.negotiate(preferences).to_string() }
}
pub fn for_language(&self, language: &str) -> Translator<'_> {
Translator { catalogs: self, locale: language.to_string() }
}
fn catalog(&self, language: &str) -> Option<&Catalog> {
self.catalogs.iter().find(|(l, _)| l.eq_ignore_ascii_case(language)).map(|(_, c)| c)
}
fn lookup(&self, language: &str, key: &str) -> Option<&str> {
let base = language.split('-').next().unwrap_or(language);
[language, base, self.default.as_str()].into_iter().find_map(|l| self.catalog(l)?.get(key))
}
}
#[derive(Debug, Clone)]
pub struct Translator<'a> {
catalogs: &'a Catalogs,
locale: String,
}
impl Translator<'_> {
pub fn locale(&self) -> &str {
&self.locale
}
pub fn t(&self, key: &str) -> String {
self.t_with(key, &[])
}
pub fn t_with(&self, key: &str, args: &[(&str, &str)]) -> String {
match self.catalogs.lookup(&self.locale, key) {
Some(message) => format_message(message, args),
None => key.to_string(),
}
}
pub fn plural(&self, key: &str, count: u64) -> String {
self.plural_with(key, count, &[])
}
pub fn plural_with(&self, key: &str, count: u64, args: &[(&str, &str)]) -> String {
let count_text = count.to_string();
let mut all: Vec<(&str, &str)> = vec![("count", &count_text)];
all.extend_from_slice(args);
let category = plural_category(&self.locale, count);
let chosen = format!("{key}.{category}");
match self.catalogs.lookup(&self.locale, &chosen).or_else(|| self.catalogs.lookup(&self.locale, &format!("{key}.other"))) {
Some(message) => format_message(message, &all),
None => chosen,
}
}
pub fn localize(&self, mut response: Response) -> Response {
use crate::header::Header;
response.headers.retain(|h| !h.name.eq_ignore_ascii_case(Header::_CONTENT_LANGUAGE));
response.headers.push(Header::new(Header::_CONTENT_LANGUAGE, self.locale.clone()));
match response.headers.iter_mut().find(|h| h.name.eq_ignore_ascii_case(Header::_VARY)) {
Some(vary) if vary.value.split(',').any(|v| v.trim().eq_ignore_ascii_case("accept-language")) => {}
Some(vary) => vary.value = format!("{}, Accept-Language", vary.value).into(),
None => response.headers.push(Header::new(Header::_VARY, "Accept-Language".to_string())),
}
response
}
}
fn format_message(message: &str, args: &[(&str, &str)]) -> String {
let mut out = String::with_capacity(message.len());
let mut rest = message;
while let Some(i) = rest.find(['{', '}']) {
out.push_str(&rest[..i]);
let tail = &rest[i..];
if tail.starts_with("{{") || tail.starts_with("}}") {
out.push_str(&tail[..1]);
rest = &tail[2..];
} else if let Some(end) = tail.strip_prefix('{').and_then(|t| t.find('}')) {
let name = &tail[1..1 + end];
match args.iter().find(|(n, _)| *n == name) {
Some((_, value)) => out.push_str(value),
None => out.push_str(&tail[..end + 2]),
}
rest = &tail[end + 2..];
} else {
out.push_str(&tail[..1]);
rest = &tail[1..];
}
}
out.push_str(rest);
out
}
pub fn plural_category(language: &str, count: u64) -> &'static str {
let lower = language.to_ascii_lowercase();
let base = lower.split('-').next().unwrap_or("");
let (last, last_two) = (count % 10, count % 100);
match base {
"uk" | "ru" | "be" => {
if last == 1 && last_two != 11 {
"one"
} else if (2..=4).contains(&last) && !(12..=14).contains(&last_two) {
"few"
} else {
"many"
}
}
"pl" => {
if count == 1 {
"one"
} else if (2..=4).contains(&last) && !(12..=14).contains(&last_two) {
"few"
} else {
"many"
}
}
"cs" | "sk" => match count {
1 => "one",
2..=4 => "few",
_ => "other",
},
"fr" => {
if count <= 1 {
"one"
} else {
"other"
}
}
"pt" if lower != "pt-pt" => {
if count <= 1 {
"one"
} else {
"other"
}
}
"ja" | "zh" | "ko" | "vi" | "th" | "id" | "ms" | "lo" | "my" | "km" | "tr" => "other",
_ => {
if count == 1 {
"one"
} else {
"other"
}
}
}
}
#[cfg(test)]
mod tests;