use std::fmt;
use std::str::FromStr;
use std::sync::Arc;
use super::error::{I18nError, LocaleRejection};
const MAX_TAG_LEN: usize = 35;
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[non_exhaustive]
pub struct LocaleId(Arc<str>);
impl LocaleId {
pub fn parse(tag: &str) -> Result<Self, I18nError> {
Self::well_formed(tag).map_err(I18nError::InvalidLocale)?;
let parsed: unic_langid::LanguageIdentifier = tag
.parse()
.map_err(|_| I18nError::InvalidLocale(LocaleRejection::NotWellFormed))?;
Ok(Self(Arc::from(parsed.to_string().as_str())))
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
#[must_use]
pub fn language(&self) -> &str {
self.0.split('-').next().unwrap_or(&self.0)
}
fn well_formed(tag: &str) -> Result<(), LocaleRejection> {
if tag.is_empty() {
return Err(LocaleRejection::Empty);
}
if tag.len() > MAX_TAG_LEN {
return Err(LocaleRejection::TooLong);
}
for subtag in tag.split('-') {
if subtag.is_empty() || subtag.len() > 8 {
return Err(LocaleRejection::NotWellFormed);
}
if !subtag.bytes().all(|byte| byte.is_ascii_alphanumeric()) {
return Err(LocaleRejection::NotWellFormed);
}
}
Ok(())
}
pub(crate) fn to_langid(&self) -> unic_langid::LanguageIdentifier {
self.0
.parse()
.unwrap_or_else(|_| unic_langid::LanguageIdentifier::default())
}
}
impl fmt::Display for LocaleId {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(&self.0)
}
}
impl AsRef<str> for LocaleId {
fn as_ref(&self) -> &str {
&self.0
}
}
impl FromStr for LocaleId {
type Err = I18nError;
fn from_str(tag: &str) -> Result<Self, Self::Err> {
Self::parse(tag)
}
}
impl serde::Serialize for LocaleId {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(&self.0)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_plain_language_parses() {
assert_eq!(LocaleId::parse("fr").unwrap().as_str(), "fr");
}
#[test]
fn casing_is_canonicalized() {
for spelling in ["zh-hant-hk", "ZH-HANT-HK", "zh-Hant-HK"] {
assert_eq!(LocaleId::parse(spelling).unwrap().as_str(), "zh-Hant-HK");
}
}
#[test]
fn the_language_subtag_is_the_first_one() {
assert_eq!(LocaleId::parse("pt-BR").unwrap().language(), "pt");
assert_eq!(LocaleId::parse("pt").unwrap().language(), "pt");
}
#[test]
fn hostile_shapes_are_refused() {
let cases: &[(&str, LocaleRejection)] = &[
("", LocaleRejection::Empty),
("../../etc/passwd", LocaleRejection::NotWellFormed),
("..", LocaleRejection::NotWellFormed),
("../en", LocaleRejection::NotWellFormed),
("en/../..", LocaleRejection::NotWellFormed),
("en\\..\\..", LocaleRejection::NotWellFormed),
("en\0", LocaleRejection::NotWellFormed),
("\0", LocaleRejection::NotWellFormed),
("en\nSet-Cookie: a=b", LocaleRejection::NotWellFormed),
("en US", LocaleRejection::NotWellFormed),
("en_US", LocaleRejection::NotWellFormed),
("en-", LocaleRejection::NotWellFormed),
("-en", LocaleRejection::NotWellFormed),
("en--US", LocaleRejection::NotWellFormed),
("%2e%2e%2f", LocaleRejection::NotWellFormed),
("C:", LocaleRejection::NotWellFormed),
("~", LocaleRejection::NotWellFormed),
("$(id)", LocaleRejection::NotWellFormed),
("en\u{202e}", LocaleRejection::NotWellFormed),
];
for (input, expected) in cases {
match LocaleId::parse(input) {
Err(I18nError::InvalidLocale(reason)) => {
assert_eq!(reason, *expected, "wrong rejection reason for {input:?}")
}
other => panic!("{input:?} was not rejected: {other:?}"),
}
}
}
#[test]
fn an_overlong_tag_is_refused_before_it_is_parsed() {
let long = "a".repeat(4096);
assert!(matches!(
LocaleId::parse(&long),
Err(I18nError::InvalidLocale(LocaleRejection::TooLong))
));
let boundary = "en-".to_string() + &"a".repeat(33);
assert_eq!(boundary.len(), MAX_TAG_LEN + 1);
assert!(matches!(
LocaleId::parse(&boundary),
Err(I18nError::InvalidLocale(LocaleRejection::TooLong))
));
}
#[test]
fn a_locale_serializes_as_its_tag() {
let json = serde_json::to_string(&LocaleId::parse("en-GB").unwrap()).unwrap();
assert_eq!(json, "\"en-GB\"");
}
#[test]
fn from_str_agrees_with_parse() {
let parsed: LocaleId = "de-AT".parse().unwrap();
assert_eq!(parsed, LocaleId::parse("de-AT").unwrap());
}
#[test]
fn the_canonical_tag_round_trips_into_a_langid() {
let locale = LocaleId::parse("zh-hant-hk").unwrap();
assert_eq!(locale.to_langid().to_string(), "zh-Hant-HK");
}
}