use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use crate::error::{FraiseQLError, Result};
pub const LOCALE_SESSION_VAR: &str = "fraiseql.locale";
pub const TRANSLATIONS_SUFFIX: &str = "Translations";
pub const LOCALIZED_STRING_TYPE: &str = "LocalizedString";
pub const LOCALIZED_INPUT_TYPE: &str = "LocalizedInput";
pub const LOCALIZED_STRING_INPUT_TYPE: &str = "LocalizedStringInput";
pub const MAX_ACCEPT_LANGUAGE_BYTES: usize = 1024;
const MAX_ACCEPT_LANGUAGE_ENTRIES: usize = 32;
const MAX_TAG_BYTES: usize = 35;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(untagged, deny_unknown_fields)]
pub enum LocaleSource {
Argument {
argument: String,
},
Header {
header: String,
},
Enrichment {
enrichment: String,
},
}
#[derive(Clone, Copy, Default)]
pub struct LocaleInputs<'a> {
pub argument: Option<&'a dyn Fn(&str) -> Option<String>>,
pub header: Option<&'a dyn Fn(&str) -> Option<String>>,
pub enrichment: Option<&'a dyn Fn(&str) -> Option<String>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct LocaleConfig {
pub default: String,
pub allowed: Vec<String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub fallback: BTreeMap<String, String>,
#[serde(default = "default_resolve")]
pub resolve: Vec<LocaleSource>,
#[serde(skip)]
chains: BTreeMap<String, Vec<String>>,
}
impl PartialEq for LocaleConfig {
fn eq(&self, other: &Self) -> bool {
self.default == other.default
&& self.allowed == other.allowed
&& self.fallback == other.fallback
&& self.resolve == other.resolve
}
}
impl Eq for LocaleConfig {}
fn default_resolve() -> Vec<LocaleSource> {
vec![
LocaleSource::Argument {
argument: "locale".to_string(),
},
LocaleSource::Header {
header: "Accept-Language".to_string(),
},
]
}
impl LocaleConfig {
pub fn new(
default: impl Into<String>,
allowed: Vec<String>,
fallback: BTreeMap<String, String>,
resolve: Vec<LocaleSource>,
) -> Result<Self> {
let mut config = Self {
default: default.into(),
allowed,
fallback,
resolve,
chains: BTreeMap::new(),
};
config.validate()?;
Ok(config)
}
pub fn validate(&mut self) -> Result<()> {
if self.allowed.is_empty() {
return Err(refuse("[locale] allowed must list at least one locale"));
}
for tag in &self.allowed {
check_tag(tag, "[locale] allowed")?;
}
for (i, tag) in self.allowed.iter().enumerate() {
if self.allowed[..i].contains(tag) {
return Err(refuse(format!("[locale] allowed lists `{tag}` twice")));
}
}
check_tag(&self.default, "[locale] default")?;
if !self.allowed.contains(&self.default) {
return Err(refuse(format!(
"[locale] default `{}` is not in allowed ({})",
self.default,
self.allowed.join(", ")
)));
}
for (from, to) in &self.fallback {
check_tag(from, "[locale] fallback key")?;
check_tag(to, "[locale] fallback target")?;
if !self.allowed.contains(to) {
return Err(refuse(format!(
"[locale] fallback `{from}` = `{to}`: the target is not in allowed ({})",
self.allowed.join(", ")
)));
}
if from == to {
return Err(refuse(format!("[locale] fallback `{from}` names itself")));
}
}
for from in self.fallback.keys() {
let mut seen = vec![from.as_str()];
let mut at = from.as_str();
while let Some(next) = self.fallback.get(at) {
if seen.contains(&next.as_str()) {
seen.push(next);
return Err(refuse(format!(
"[locale] fallback has a cycle: {}",
seen.join(" → ")
)));
}
seen.push(next);
at = next;
}
}
for (i, source) in self.resolve.iter().enumerate() {
let (kind, name) = match source {
LocaleSource::Argument { argument } => ("argument", argument),
LocaleSource::Header { header } => ("header", header),
LocaleSource::Enrichment { enrichment } => ("enrichment", enrichment),
};
if name.is_empty()
|| !name.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
{
return Err(refuse(format!(
"[locale] resolve: {kind} `{name}` must be a non-empty name of letters, \
digits, `_` or `-`"
)));
}
if self.resolve[..i].contains(source) {
return Err(refuse(format!("[locale] resolve lists {kind} `{name}` twice")));
}
}
self.chains =
self.allowed.iter().map(|tag| (tag.clone(), self.derive_chain(tag))).collect();
Ok(())
}
#[must_use]
pub fn chain(&self, locale: &str) -> Option<&[String]> {
self.chains.get(locale).map(Vec::as_slice)
}
#[must_use]
pub fn collation(&self, locale: &str) -> Option<String> {
self.allowed.iter().any(|a| a == locale).then(|| format!("{locale}-x-icu"))
}
pub fn collations(&self) -> impl Iterator<Item = (&str, String)> {
self.allowed.iter().map(|tag| (tag.as_str(), format!("{tag}-x-icu")))
}
pub fn enrichment_fields(&self) -> impl Iterator<Item = &str> {
self.resolve.iter().filter_map(|s| match s {
LocaleSource::Enrichment { enrichment } => Some(enrichment.as_str()),
_ => None,
})
}
#[must_use]
pub fn resolve(&self, inputs: &LocaleInputs<'_>) -> &str {
for source in &self.resolve {
let candidates: Vec<String> = match source {
LocaleSource::Argument { argument } => {
inputs.argument.and_then(|f| f(argument)).into_iter().collect()
},
LocaleSource::Header { header } => {
let Some(value) = inputs.header.and_then(|f| f(header)) else {
continue;
};
if header.eq_ignore_ascii_case("accept-language") {
accept_language(&value)
} else {
vec![value]
}
},
LocaleSource::Enrichment { enrichment } => {
inputs.enrichment.and_then(|f| f(enrichment)).into_iter().collect()
},
};
if let Some(tag) = candidates.iter().find_map(|c| self.match_tag(c)) {
return tag;
}
}
&self.default
}
#[must_use]
pub fn match_tag(&self, requested: &str) -> Option<&str> {
let requested = requested.trim();
if requested.is_empty()
|| requested.len() > MAX_TAG_BYTES
|| !requested.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-')
{
return None;
}
let mut candidate = requested;
loop {
if let Some(tag) = self.allowed.iter().find(|t| t.eq_ignore_ascii_case(candidate)) {
return Some(tag);
}
if let Some((_, to)) =
self.fallback.iter().find(|(from, _)| from.eq_ignore_ascii_case(candidate))
{
return Some(to);
}
candidate = truncate(candidate)?;
}
}
pub(crate) fn derive_chain(&self, tag: &str) -> Vec<String> {
let mut chain: Vec<String> = vec![tag.to_string()];
let mut push = |t: &str| {
if !chain.iter().any(|c| c == t) {
chain.push(t.to_string());
}
};
let mut at = tag;
while let Some(next) = self.fallback.get(at) {
push(next);
at = next;
}
let mut shorter = truncate(tag);
while let Some(t) = shorter {
if self.allowed.iter().any(|a| a == t) {
push(t);
}
shorter = truncate(t);
}
push(&self.default);
chain
}
}
fn truncate(tag: &str) -> Option<&str> {
let mut shorter = &tag[..tag.rfind('-')?];
if let Some(i) = shorter.rfind('-') {
if shorter.len() - i == 2 {
shorter = &shorter[..i];
}
}
Some(shorter)
}
fn accept_language(value: &str) -> Vec<String> {
if value.len() > MAX_ACCEPT_LANGUAGE_BYTES {
return Vec::new();
}
let mut weighted: Vec<(u16, String)> = value
.split(',')
.take(MAX_ACCEPT_LANGUAGE_ENTRIES)
.filter_map(|entry| {
let mut parts = entry.split(';');
let tag = parts.next()?.trim();
let mut q = 1000;
for param in parts {
let (key, val) = param.split_once('=')?;
if key.trim().eq_ignore_ascii_case("q") {
q = parse_qvalue(val.trim())?;
}
}
(q > 0 && !tag.is_empty() && tag != "*").then(|| (q, tag.to_string()))
})
.collect();
weighted.sort_by_key(|(q, _)| std::cmp::Reverse(*q));
weighted.into_iter().map(|(_, tag)| tag).collect()
}
fn parse_qvalue(text: &str) -> Option<u16> {
let (int, frac) = text.split_once('.').unwrap_or((text, ""));
if frac.len() > 3 || !frac.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
let frac_value = format!("{frac:0<3}").parse::<u16>().ok()?;
match int {
"0" => Some(frac_value),
"1" if frac_value == 0 => Some(1000),
_ => None,
}
}
fn check_tag(tag: &str, what: &str) -> Result<()> {
match canonical_tag(tag) {
Some(canonical) if canonical == tag => Ok(()),
Some(canonical) => Err(refuse(format!(
"{what} `{tag}` must be written in canonical casing: `{canonical}`"
))),
None => Err(refuse(format!(
"{what} `{tag}` is not a well-formed BCP 47 language tag (language, then \
optional script, region and variants, e.g. `fr`, `fr-FR`, `zh-Hant-TW`)"
))),
}
}
fn canonical_tag(tag: &str) -> Option<String> {
if tag.is_empty() || tag.len() > MAX_TAG_BYTES {
return None;
}
let mut subtags = tag.split('-').peekable();
let language = subtags.next()?;
if !(matches!(language.len(), 2 | 3 | 5..=8)
&& language.bytes().all(|b| b.is_ascii_alphabetic()))
{
return None;
}
let mut out = vec![language.to_ascii_lowercase()];
if let Some(script) =
subtags.next_if(|s| s.len() == 4 && s.bytes().all(|b| b.is_ascii_alphabetic()))
{
let mut chars = script.chars();
let first = chars.next()?.to_ascii_uppercase();
out.push(format!("{first}{}", chars.as_str().to_ascii_lowercase()));
}
if let Some(region) = subtags.next_if(|s| {
(s.len() == 2 && s.bytes().all(|b| b.is_ascii_alphabetic()))
|| (s.len() == 3 && s.bytes().all(|b| b.is_ascii_digit()))
}) {
out.push(region.to_ascii_uppercase());
}
for variant in subtags {
let alnum = variant.bytes().all(|b| b.is_ascii_alphanumeric());
let ok = alnum
&& (matches!(variant.len(), 5..=8)
|| (variant.len() == 4 && variant.as_bytes()[0].is_ascii_digit()));
if !ok {
return None;
}
out.push(variant.to_ascii_lowercase());
}
Some(out.join("-"))
}
fn refuse(message: impl Into<String>) -> FraiseQLError {
FraiseQLError::Validation {
message: message.into(),
path: Some("locale".to_string()),
}
}
#[cfg(test)]
mod tests;