use alloc::string::{String, ToString};
use alloc::vec::Vec;
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct Locale {
pub language: String,
pub script: Option<String>,
pub region: Option<String>,
pub variants: Vec<String>,
pub extensions: Vec<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ParseError {
Empty,
InvalidSubtag,
}
fn is_alpha(s: &str) -> bool {
s.bytes().all(|b| b.is_ascii_alphabetic())
}
fn is_digit(s: &str) -> bool {
s.bytes().all(|b| b.is_ascii_digit())
}
fn is_alnum(s: &str) -> bool {
s.bytes().all(|b| b.is_ascii_alphanumeric())
}
impl Locale {
pub fn parse(tag: &str) -> Result<Locale, ParseError> {
if tag.is_empty() {
return Err(ParseError::Empty);
}
let mut parts = tag.split(['-', '_']).peekable();
let mut loc = Locale::default();
let lang = parts.next().ok_or(ParseError::Empty)?;
if lang.is_empty() || !((2..=8).contains(&lang.len()) && is_alpha(lang)) {
return Err(ParseError::InvalidSubtag);
}
loc.language = if lang.eq_ignore_ascii_case("und") {
String::new()
} else {
lang.to_ascii_lowercase()
};
if let Some(&s) = parts.peek()
&& s.len() == 4
&& is_alpha(s)
{
loc.script = Some(titlecase_subtag(s));
parts.next();
}
if let Some(&s) = parts.peek()
&& ((s.len() == 2 && is_alpha(s)) || (s.len() == 3 && is_digit(s)))
{
loc.region = Some(s.to_ascii_uppercase());
parts.next();
}
while let Some(&s) = parts.peek() {
let is_variant = ((5..=8).contains(&s.len()) && is_alnum(s))
|| (s.len() == 4 && s.as_bytes()[0].is_ascii_digit() && is_alnum(s));
if !is_variant {
break;
}
loc.variants.push(s.to_ascii_lowercase());
parts.next();
}
let mut current: Option<String> = None;
for p in parts {
let in_private = current.as_deref().is_some_and(|c| c.starts_with('x'));
if p.len() == 1 && p.bytes().next().unwrap().is_ascii_alphanumeric() && !in_private {
if let Some(ext) = current.take() {
if !is_valid_extension(&ext) {
return Err(ParseError::InvalidSubtag);
}
loc.extensions.push(ext);
}
current = Some(p.to_ascii_lowercase());
} else if let Some(ext) = current.as_mut() {
if p.is_empty() || !p.bytes().all(|b| b.is_ascii_alphanumeric()) {
return Err(ParseError::InvalidSubtag);
}
ext.push('-');
ext.push_str(&p.to_ascii_lowercase());
} else {
return Err(ParseError::InvalidSubtag); }
}
if let Some(ext) = current {
if !is_valid_extension(&ext) {
return Err(ParseError::InvalidSubtag);
}
loc.extensions.push(ext);
}
loc.extensions.sort_by(|a, b| {
let key = |s: &String| (s.starts_with("x-"), s.clone());
key(a).cmp(&key(b))
});
Ok(loc)
}
#[must_use]
pub fn maximize(&self) -> Locale {
let lang = if self.language.is_empty() {
"und"
} else {
&self.language
};
let mut candidates: Vec<String> = Vec::new();
if let (Some(s), Some(r)) = (&self.script, &self.region) {
candidates.push(alloc::format!("{lang}-{s}-{r}"));
}
if let Some(r) = &self.region {
candidates.push(alloc::format!("{lang}-{r}"));
}
if let Some(s) = &self.script {
candidates.push(alloc::format!("{lang}-{s}"));
}
candidates.push(String::from(lang));
for key in &candidates {
if let Some(v) = crate::cldr::likely_subtags(key)
&& let Ok(m) = Locale::parse(v)
{
return Locale {
language: if self.language.is_empty() {
m.language
} else {
self.language.clone()
},
script: self.script.clone().or(m.script),
region: self.region.clone().or(m.region),
variants: self.variants.clone(),
extensions: self.extensions.clone(),
};
}
}
self.clone()
}
#[must_use]
pub fn minimize(&self) -> Locale {
let max = self.maximize();
let lang_only = Locale {
language: self.language.clone(),
..Locale::default()
};
let lang_region = Locale {
language: self.language.clone(),
region: self.region.clone(),
..Locale::default()
};
let lang_script = Locale {
language: self.language.clone(),
script: self.script.clone(),
..Locale::default()
};
for trial in [lang_only, lang_region, lang_script] {
if trial.maximize() == max {
return trial;
}
}
max
}
}
impl core::fmt::Display for Locale {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
if self.language.is_empty() {
f.write_str("und")?;
} else {
f.write_str(&self.language)?;
}
if let Some(s) = &self.script {
write!(f, "-{s}")?;
}
if let Some(r) = &self.region {
write!(f, "-{r}")?;
}
for v in &self.variants {
write!(f, "-{v}")?;
}
for e in &self.extensions {
write!(f, "-{e}")?;
}
Ok(())
}
}
#[must_use]
pub fn negotiate(requested: &[&str], available: &[Locale]) -> Option<usize> {
let maxed: Vec<Locale> = available.iter().map(Locale::maximize).collect();
for req in requested {
let Ok(r) = Locale::parse(req) else { continue };
let r = r.maximize();
for level in 0..3 {
for (i, a) in maxed.iter().enumerate() {
let hit = match level {
0 => a.language == r.language && a.script == r.script && a.region == r.region,
1 => a.language == r.language && a.script == r.script,
_ => a.language == r.language,
};
if hit {
return Some(i);
}
}
}
}
None
}
#[must_use]
pub fn canonicalize(tag: &str) -> Option<String> {
let whole_key = alloc::format!("l{}", tag.to_ascii_lowercase().replace('-', "_"));
let working = match crate::cldr::alias_lookup(&whole_key) {
Some(repl) => subtags_to_tag(repl),
None => String::from(tag),
};
let mut loc = Locale::parse(&working).ok()?;
let mut lang_repl: Option<(&'static str, bool)> = None;
if let Some(region) = &loc.region {
let key = alloc::format!("l{}_{}", loc.language, region.to_ascii_lowercase());
if let Some(r) = crate::cldr::alias_lookup(&key) {
lang_repl = Some((r, true));
}
}
if lang_repl.is_none() && !loc.language.is_empty() {
let key = alloc::format!("l{}", loc.language);
if let Some(r) = crate::cldr::alias_lookup(&key) {
lang_repl = Some((r, false));
}
}
if let Some((repl, consumes_region)) = lang_repl
&& let Ok(rl) = Locale::parse(&subtags_to_tag(repl))
{
loc.language = rl.language;
if consumes_region {
loc.region = None;
}
if loc.script.is_none() {
loc.script = rl.script;
}
if loc.region.is_none() {
loc.region = rl.region;
}
if loc.variants.is_empty() {
loc.variants = rl.variants;
}
}
if let Some(script) = &loc.script {
let key = alloc::format!("s{script}");
if let Some(r) = crate::cldr::alias_lookup(&key) {
loc.script = Some(String::from(r));
}
}
for v in &mut loc.variants {
let key = alloc::format!("v{v}");
if let Some(r) = crate::cldr::alias_lookup(&key) {
*v = String::from(r);
}
}
if let Some(region) = loc.region.clone() {
let key = alloc::format!("t{region}");
if let Some(r) = crate::cldr::alias_lookup(&key) {
loc.region = Some(pick_territory(&loc, r));
}
}
for e in &mut loc.extensions {
if let Some(body) = e.strip_prefix("u-") {
*e = canonicalize_unicode_ext(body);
} else if let Some(body) = e.strip_prefix("t-") {
*e = canonicalize_transform_ext(body);
}
}
Some(loc.to_string())
}
fn canonicalize_unicode_ext(body: &str) -> String {
let subs: Vec<&str> = body.split('-').collect();
let mut i = 0;
let mut attrs: Vec<&str> = Vec::new();
while i < subs.len() && subs[i].len() != 2 {
attrs.push(subs[i]);
i += 1;
}
attrs.sort_unstable();
attrs.dedup();
let mut keywords: Vec<(&str, String)> = Vec::new();
while i < subs.len() {
let key = subs[i];
i += 1;
let start = i;
while i < subs.len() && subs[i].len() != 2 {
i += 1;
}
if keywords.iter().any(|(k, _)| *k == key) {
continue;
}
let value = subs[start..i].join("-");
keywords.push((key, canonical_keyword_type(key, &value)));
}
keywords.sort_by(|a, b| a.0.cmp(b.0));
let mut out = String::from("u");
for a in &attrs {
out.push('-');
out.push_str(a);
}
for (key, value) in &keywords {
out.push('-');
out.push_str(key);
if !value.is_empty() {
out.push('-');
out.push_str(value);
}
}
out
}
fn canonical_keyword_type(key: &str, value: &str) -> String {
if value.is_empty() {
return String::new();
}
let alias_key = alloc::format!("{key}/{value}");
let canonical = crate::cldr::bcp47_type_alias(&alias_key).unwrap_or(value);
if canonical == "true" || canonical == "yes" {
String::new()
} else {
String::from(canonical)
}
}
fn canonicalize_transform_ext(body: &str) -> String {
let subs: Vec<&str> = body.split('-').collect();
let is_tkey = |s: &str| {
s.len() == 2 && s.as_bytes()[0].is_ascii_alphabetic() && s.as_bytes()[1].is_ascii_digit()
};
let mut i = 0;
while i < subs.len() && !is_tkey(subs[i]) {
i += 1;
}
let tlang = if i > 0 {
let joined = subs[..i].join("-");
canonicalize(&joined).unwrap_or(joined).to_ascii_lowercase()
} else {
String::new()
};
let mut fields: Vec<(&str, String)> = Vec::new();
while i < subs.len() {
let key = subs[i];
i += 1;
let start = i;
while i < subs.len() && !is_tkey(subs[i]) {
i += 1;
}
if fields.iter().any(|(k, _)| *k == key) {
continue;
}
let value = subs[start..i].join("-");
let alias_key = alloc::format!("{key}/{value}");
let canonical = crate::cldr::bcp47_type_alias(&alias_key).unwrap_or(&value);
fields.push((key, String::from(canonical)));
}
fields.sort_by(|a, b| a.0.cmp(b.0));
let mut out = String::from("t");
if !tlang.is_empty() {
out.push('-');
out.push_str(&tlang);
}
for (key, value) in &fields {
out.push('-');
out.push_str(key);
if !value.is_empty() {
out.push('-');
out.push_str(value);
}
}
out
}
fn subtags_to_tag(subtags: &str) -> String {
let mut out = String::with_capacity(subtags.len());
for (i, s) in subtags.split(' ').enumerate() {
if i > 0 {
out.push('-');
}
out.push_str(s);
}
out
}
fn pick_territory(loc: &Locale, replacement: &str) -> String {
let first = replacement.split(' ').next().unwrap_or("");
if !replacement.contains(' ') {
return String::from(first);
}
let probe = Locale {
language: loc.language.clone(),
script: loc.script.clone(),
..Locale::default()
};
if let Some(likely) = probe.maximize().region
&& replacement.split(' ').any(|c| c == likely)
{
return likely;
}
String::from(first)
}
#[must_use]
pub fn get_canonical_locales(tags: &[&str]) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
for &tag in tags {
if let Some(c) = canonicalize(tag)
&& !out.iter().any(|x| x == &c)
{
out.push(c);
}
}
out
}
fn is_valid_extension(ext: &str) -> bool {
let mut subs = ext.split('-');
let singleton = subs.next().unwrap_or("");
match singleton {
"t" => is_valid_transform(subs),
"x" => {
let mut any = false;
for s in subs {
any = true;
if !(1..=8).contains(&s.len()) || !is_alnum(s) {
return false;
}
}
any
}
"u" => {
let mut any = false;
for s in subs {
any = true;
let is_key = s.len() == 2 && is_alnum(s) && s.as_bytes()[1].is_ascii_alphabetic();
let is_attr_or_type = (3..=8).contains(&s.len()) && is_alnum(s);
if !is_key && !is_attr_or_type {
return false;
}
}
any
}
_ => {
let mut any = false;
for s in subs {
any = true;
if !(2..=8).contains(&s.len()) || !is_alnum(s) {
return false;
}
}
any
}
}
}
fn is_valid_transform<'a>(subs: impl Iterator<Item = &'a str>) -> bool {
let subs: Vec<&str> = subs.collect();
let is_tkey = |s: &str| {
s.len() == 2 && s.as_bytes()[0].is_ascii_alphabetic() && s.as_bytes()[1].is_ascii_digit()
};
let split = subs.iter().position(|s| is_tkey(s)).unwrap_or(subs.len());
if split > 0 && Locale::parse(&subs[..split].join("-")).is_err() {
return false;
}
let mut i = split;
let mut fields = 0;
while i < subs.len() {
i += 1; let start = i;
while i < subs.len() && !is_tkey(subs[i]) {
if !(3..=8).contains(&subs[i].len()) || !is_alnum(subs[i]) {
return false;
}
i += 1;
}
if start == i {
return false; }
fields += 1;
}
split > 0 || fields > 0
}
fn titlecase_subtag(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for (i, b) in s.bytes().enumerate() {
out.push(if i == 0 {
b.to_ascii_uppercase() as char
} else {
b.to_ascii_lowercase() as char
});
}
out
}