use alloc::borrow::{Cow, ToOwned};
use alloc::format;
use alloc::string::String;
use alloc::sync::Arc;
use alloc::vec::Vec;
use ::http::header::{ACCEPT_LANGUAGE, COOKIE, HeaderName, HeaderValue, SET_COOKIE};
use ::http::request::Parts;
use mf2_catalog::Dir;
#[derive(Clone, PartialEq, Eq, Debug)]
#[non_exhaustive]
pub struct Negotiated {
pub tag: &'static str,
pub dir: Dir,
pub from: &'static str,
pub matched: bool,
}
impl Negotiated {
#[must_use]
pub fn dir_attr(&self) -> &'static str {
if self.dir == Dir::Rtl { "rtl" } else { "ltr" }
}
}
pub trait LocaleSource: Send + Sync + std::fmt::Debug {
fn name(&self) -> &'static str;
fn vary(&self) -> Option<HeaderName> {
None
}
fn query(&self) -> Option<&'static str> {
None
}
fn candidates<'r>(&self, parts: &'r Parts, out: &mut Vec<Cow<'r, str>>);
}
pub trait LocaleSink: Send + Sync + std::fmt::Debug {
fn name(&self) -> &'static str;
fn store(&self, negotiated: &Negotiated) -> Option<(HeaderName, HeaderValue)>;
}
#[derive(Clone, Debug)]
pub struct CookieLocale {
pub name: &'static str,
pub max_age: u32,
pub path: &'static str,
pub same_site: &'static str,
pub secure: bool,
}
impl Default for CookieLocale {
fn default() -> CookieLocale {
CookieLocale {
name: crate::links::LOCALE_COOKIE,
max_age: 31_536_000,
path: "/",
same_site: "Lax",
secure: true,
}
}
}
impl LocaleSource for CookieLocale {
fn name(&self) -> &'static str {
"cookie"
}
fn vary(&self) -> Option<HeaderName> {
Some(COOKIE)
}
fn candidates<'r>(&self, parts: &'r Parts, out: &mut Vec<Cow<'r, str>>) {
if let Some(value) = cookie(parts, self.name) {
out.push(Cow::Borrowed(value));
}
}
}
pub(crate) fn cookie<'r>(parts: &'r Parts, name: &str) -> Option<&'r str> {
let header = parts.headers.get(COOKIE).and_then(|v| v.to_str().ok())?;
header.split(';').find_map(|pair| {
let (n, value) = pair.trim_start().split_once('=')?;
(n.trim() == name).then(|| value.trim())
})
}
impl LocaleSink for CookieLocale {
fn name(&self) -> &'static str {
"cookie"
}
fn store(&self, negotiated: &Negotiated) -> Option<(HeaderName, HeaderValue)> {
if !matches!(negotiated.from, "query" | "path") {
return None;
}
let mut cookie = format!(
"{}={}; Max-Age={}; Path={}; SameSite={}",
self.name, negotiated.tag, self.max_age, self.path, self.same_site
);
if self.secure {
cookie.push_str("; Secure");
}
HeaderValue::from_str(&cookie)
.ok()
.map(|value| (SET_COOKIE, value))
}
}
#[derive(Clone, Copy, Debug, Default)]
pub struct AcceptLanguage;
impl LocaleSource for AcceptLanguage {
fn name(&self) -> &'static str {
"accept-language"
}
fn vary(&self) -> Option<HeaderName> {
Some(ACCEPT_LANGUAGE)
}
fn candidates<'r>(&self, parts: &'r Parts, out: &mut Vec<Cow<'r, str>>) {
let Some(header) = parts
.headers
.get(ACCEPT_LANGUAGE)
.and_then(|v| v.to_str().ok())
else {
return;
};
let mut ranked: Vec<(u32, &str)> = Vec::new();
for item in header.split(',') {
let mut fields = item.split(';');
let Some(tag) = fields.next().map(str::trim) else {
continue;
};
if tag.is_empty() {
continue;
}
let quality = fields
.find_map(|f| f.trim().strip_prefix("q=").map(quality_milli))
.unwrap_or(1000);
if quality > 0 {
ranked.push((quality, tag));
}
}
ranked.sort_by_key(|(quality, _)| core::cmp::Reverse(*quality));
out.extend(ranked.into_iter().map(|(_, tag)| Cow::Borrowed(tag)));
}
}
fn quality_milli(text: &str) -> u32 {
let text = text.trim();
let (whole, fraction) = text.split_once('.').unwrap_or((text, ""));
let whole: u32 = whole.parse().unwrap_or(1);
let mut milli = whole.saturating_mul(1000);
for (i, digit) in fraction.chars().take(3).enumerate() {
let Some(d) = digit.to_digit(10) else { break };
let scale = match i {
0 => 100,
1 => 10,
_ => 1,
};
milli += d * scale;
}
milli.min(1000)
}
#[derive(Clone, Copy, Debug, Default)]
pub struct PathPrefix;
impl LocaleSource for PathPrefix {
fn name(&self) -> &'static str {
"path"
}
fn candidates<'r>(&self, parts: &'r Parts, out: &mut Vec<Cow<'r, str>>) {
let path = parts.uri.path();
let first = path.trim_start_matches('/').split('/').next();
if let Some(segment) = first
&& !segment.is_empty()
{
out.push(Cow::Borrowed(segment));
}
}
}
#[derive(Clone, Copy, Debug)]
pub struct QueryParam(pub &'static str);
impl Default for QueryParam {
fn default() -> QueryParam {
QueryParam(crate::links::LOCALE_QUERY)
}
}
impl LocaleSource for QueryParam {
fn name(&self) -> &'static str {
"query"
}
fn query(&self) -> Option<&'static str> {
Some(self.0)
}
fn candidates<'r>(&self, parts: &'r Parts, out: &mut Vec<Cow<'r, str>>) {
let Some(query) = parts.uri.query() else {
return;
};
for pair in query.split('&') {
if let Some((name, value)) = pair.split_once('=')
&& name == self.0
&& !value.is_empty()
{
out.push(Cow::Borrowed(value));
return;
}
}
}
}
#[derive(Clone, Debug)]
pub struct Negotiator {
sources: Vec<Arc<dyn LocaleSource>>,
sinks: Vec<Arc<dyn LocaleSink>>,
locales: &'static [(&'static str, Dir)],
default: &'static str,
vary: Option<HeaderValue>,
}
impl Negotiator {
#[must_use]
pub fn empty() -> Negotiator {
Negotiator::over(super::locales(), super::source_locale())
}
#[must_use]
pub fn over(locales: &'static [(&'static str, Dir)], default: &'static str) -> Negotiator {
Negotiator {
sources: Vec::new(),
sinks: Vec::new(),
locales,
default,
vary: None,
}
}
#[must_use]
pub fn source(mut self, source: impl LocaleSource + 'static) -> Negotiator {
self.sources.push(Arc::new(source));
self.vary = None;
self
}
#[must_use]
pub fn sink(mut self, sink: impl LocaleSink + 'static) -> Negotiator {
self.sinks.push(Arc::new(sink));
self
}
#[must_use]
pub fn default_locale(mut self, tag: &str) -> Negotiator {
if let Some((found, _)) = self.locales.iter().find(|(t, _)| *t == tag) {
self.default = found;
}
self
}
#[must_use]
pub fn query_name(&self) -> Option<&'static str> {
self.sources.iter().find_map(|source| source.query())
}
#[must_use]
pub fn locales(&self) -> &'static [(&'static str, Dir)] {
self.locales
}
#[must_use]
pub fn negotiate(&self, parts: &Parts) -> Negotiated {
let mut candidates: Vec<Cow<'_, str>> = Vec::new();
for source in &self.sources {
candidates.clear();
source.candidates(parts, &mut candidates);
if let Some((tag, dir)) =
super::best_locale(candidates.iter().map(AsRef::as_ref), self.locales)
{
return Negotiated {
tag,
dir,
from: source.name(),
matched: true,
};
}
}
self.unmatched(parts);
Negotiated {
tag: self.default,
dir: self.dir_of(self.default),
from: "default",
matched: false,
}
}
fn unmatched(&self, parts: &Parts) {
let mut named: Vec<String> = Vec::new();
let mut candidates: Vec<Cow<'_, str>> = Vec::new();
for source in &self.sources {
candidates.clear();
source.candidates(parts, &mut candidates);
for candidate in &candidates {
if let Some(tag) = crate::warn::tag(candidate)
&& named.len() < 8
&& !named.iter().any(|n| n.eq_ignore_ascii_case(tag))
{
named.push(tag.to_owned());
}
}
}
let Some(first) = named.first() else {
return;
};
crate::warn::once_for(
crate::warn::Kind::Unmatched,
&first.to_ascii_lowercase(),
|| {
format!(
"mf2: no catalog matches the reader's languages ({}), so they are served \
the default language, `{}`; a catalog for one of them would serve them",
named.join(", "),
self.default
)
},
);
}
fn dir_of(&self, tag: &str) -> Dir {
self.locales
.iter()
.find(|(t, _)| *t == tag)
.map_or(Dir::Ltr, |(_, d)| *d)
}
#[must_use]
pub fn vary(&self) -> Option<HeaderValue> {
if let Some(vary) = &self.vary {
return Some(vary.clone());
}
let mut names: Vec<String> = Vec::new();
for source in &self.sources {
if let Some(name) = source.vary()
&& !names.iter().any(|n| n.eq_ignore_ascii_case(name.as_str()))
{
names.push(name.as_str().to_owned());
}
}
if names.is_empty() {
return None;
}
HeaderValue::from_str(&names.join(", ")).ok()
}
pub fn store(
&self,
negotiated: &Negotiated,
) -> impl Iterator<Item = (HeaderName, HeaderValue)> {
self.sinks
.iter()
.filter_map(move |sink| sink.store(negotiated))
}
}
impl Default for Negotiator {
fn default() -> Negotiator {
Negotiator::empty().defaults()
}
}
impl Negotiator {
pub(crate) fn defaults(self) -> Negotiator {
self.source(QueryParam::default())
.source(CookieLocale::default())
.source(AcceptLanguage)
.sink(CookieLocale {
secure: !cfg!(debug_assertions),
..CookieLocale::default()
})
}
}
pub(crate) fn lookup(
candidate: &str,
locales: &[(&'static str, Dir)],
) -> Option<(&'static str, Dir)> {
super::best_locale([candidate], locales)
}
#[cfg(test)]
#[allow(clippy::expect_used, clippy::indexing_slicing, reason = "a test")]
mod tests {
use super::{
AcceptLanguage, CookieLocale, Dir, LocaleSink, LocaleSource, Negotiated, Negotiator,
lookup, quality_milli,
};
use ::http::Request;
use alloc::borrow::{Cow, ToOwned};
use alloc::string::String;
use alloc::vec::Vec;
static LOCALES: &[(&str, Dir)] = &[("en", Dir::Ltr), ("fr-CA", Dir::Ltr), ("ar", Dir::Rtl)];
fn negotiator() -> Negotiator {
Negotiator::over(LOCALES, "en")
}
fn parts(headers: &[(&str, &str)], uri: &str) -> http::request::Parts {
let mut builder = Request::builder().uri(uri);
for (name, value) in headers {
builder = builder.header(*name, *value);
}
builder.body(()).expect("a request").into_parts().0
}
#[test]
fn lookup_finds_the_closest_language_by_cldr() {
assert_eq!(lookup("en", LOCALES).map(|l| l.0), Some("en"));
assert_eq!(lookup("EN-GB", LOCALES).map(|l| l.0), Some("en"));
assert_eq!(lookup("fr", LOCALES).map(|l| l.0), Some("fr-CA"));
assert_eq!(lookup("de", LOCALES), None);
assert_eq!(lookup("*", LOCALES), None);
}
#[test]
fn a_source_list_is_matched_as_one_list() {
let negotiator = Negotiator::over(
&[("de", Dir::Ltr), ("fr", Dir::Ltr), ("zh", Dir::Ltr)],
"de",
)
.source(AcceptLanguage);
let pick = |header: &str| {
negotiator
.negotiate(&parts(&[("accept-language", header)], "/"))
.tag
};
assert_eq!(pick("de-AT, fr;q=0.9"), "de");
assert_eq!(pick("zh-TW, zh;q=0.9"), "zh");
assert_eq!(pick("zh-TW, fr;q=0.5"), "fr");
}
#[test]
fn a_reader_no_catalog_matches_is_named_once_per_language() {
let negotiator =
Negotiator::over(&[("en", Dir::Ltr), ("zh", Dir::Ltr)], "en").source(AcceptLanguage);
for _ in 0..3 {
let answer = negotiator.negotiate(&parts(
&[("accept-language", "zh-TW, zh-HK;q=0.8, *;q=0.1")],
"/",
));
assert_eq!((answer.tag, answer.matched), ("en", false));
}
let _ = negotiator.negotiate(&parts(&[], "/"));
let lines = crate::warn::given(crate::warn::Kind::Unmatched);
let named: Vec<&String> = lines
.iter()
.filter(|l| l.contains("(zh-TW, zh-HK)"))
.collect();
assert_eq!(named.len(), 1, "{lines:?}");
assert!(named[0].contains("`en`"));
}
#[test]
fn accept_language_is_read_in_quality_order() {
let parts = parts(&[("accept-language", "de;q=0.9, fr;q=0.95, en;q=0.2")], "/");
let mut out: Vec<Cow<'_, str>> = Vec::new();
AcceptLanguage.candidates(&parts, &mut out);
assert_eq!(out, ["fr", "de", "en"]);
}
#[test]
fn quality_parses_thousandths_and_clamps() {
assert_eq!(quality_milli("1"), 1000);
assert_eq!(quality_milli("0.5"), 500);
assert_eq!(quality_milli("0.333"), 333);
assert_eq!(quality_milli("0.3339"), 333);
assert_eq!(quality_milli("7"), 1000);
}
#[test]
fn the_first_source_that_matches_wins() {
let negotiator = negotiator()
.source(super::CookieLocale::default())
.source(AcceptLanguage);
let parts = parts(
&[
("cookie", "theme=dark; mf2_locale=ar"),
("accept-language", "en"),
],
"/",
);
let negotiated = negotiator.negotiate(&parts);
assert_eq!(negotiated.tag, "ar");
assert_eq!(negotiated.from, "cookie");
assert_eq!(negotiated.dir_attr(), "rtl");
}
#[test]
fn the_default_is_query_then_cookie_then_accept_language() {
let negotiator = negotiator().defaults();
assert_eq!(negotiator.query_name(), Some("lang"));
let all = [("cookie", "mf2_locale=fr-CA"), ("accept-language", "en")];
let negotiated = negotiator.negotiate(&parts(&all, "/?x=1&lang=ar"));
assert_eq!((negotiated.tag, negotiated.from), ("ar", "query"));
let negotiated = negotiator.negotiate(&parts(&all, "/"));
assert_eq!((negotiated.tag, negotiated.from), ("fr-CA", "cookie"));
let negotiated = negotiator.negotiate(&parts(&all[1..], "/"));
assert_eq!((negotiated.tag, negotiated.from), ("en", "accept-language"));
let negotiated = negotiator.negotiate(&parts(&[], "/?lang=ar"));
let cookie: Vec<String> = negotiator
.store(&negotiated)
.map(|(_, v)| v.to_str().unwrap_or("").to_owned())
.collect();
let secure = if cfg!(debug_assertions) {
""
} else {
"; Secure"
};
assert_eq!(
cookie,
[alloc::format!(
"mf2_locale=ar; Max-Age=31536000; Path=/; SameSite=Lax{secure}"
)]
);
}
#[test]
fn the_query_name_is_the_first_query_source() {
assert_eq!(negotiator().query_name(), None);
let negotiator = negotiator()
.source(AcceptLanguage)
.source(super::QueryParam("hl"))
.source(super::QueryParam::default());
assert_eq!(negotiator.query_name(), Some("hl"));
}
#[test]
fn a_request_with_nothing_to_go_on_gets_the_default_and_says_so() {
let negotiator = negotiator().source(AcceptLanguage);
let negotiated = negotiator.negotiate(&parts(&[], "/"));
assert!(!negotiated.matched);
assert_eq!(negotiated.from, "default");
}
#[test]
fn vary_names_every_header_a_source_reads() {
let negotiator = negotiator()
.source(super::CookieLocale::default())
.source(AcceptLanguage)
.source(super::PathPrefix);
let vary = negotiator.vary().expect("two headers are read");
assert_eq!(vary.to_str().unwrap_or(""), "cookie, accept-language");
}
#[test]
fn a_path_prefix_is_read_from_the_first_segment() {
let negotiator = negotiator().source(super::PathPrefix);
assert_eq!(negotiator.negotiate(&parts(&[], "/ar/inbox")).tag, "ar");
assert!(!negotiator.negotiate(&parts(&[], "/inbox")).matched);
}
fn stored_from(from: &'static str) -> Option<String> {
let negotiated = Negotiated {
tag: "fr-CA",
dir: Dir::Ltr,
from,
matched: from != "default",
};
CookieLocale::default()
.store(&negotiated)
.map(|(_, value)| value.to_str().unwrap_or("").to_owned())
}
#[test]
fn the_cookie_is_written_for_an_explicit_choice() {
for from in ["query", "path"] {
assert_eq!(
stored_from(from).as_deref(),
Some("mf2_locale=fr-CA; Max-Age=31536000; Path=/; SameSite=Lax; Secure"),
"from {from}"
);
}
}
#[test]
fn the_cookie_is_not_written_for_a_guess_or_a_cookie_already_there() {
for from in ["cookie", "accept-language", "default"] {
assert_eq!(stored_from(from), None, "from {from}");
}
}
}