use crate::error::{Error, Result};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Lang {
EnUs,
EnGb,
Es,
Fr,
Ja,
Hi,
It,
Pt,
Zh,
Ko,
Ms,
Other(LangTag),
}
const MAX_TAG_LEN: usize = 16;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct LangTag {
bytes: [u8; MAX_TAG_LEN],
len: u8,
}
impl LangTag {
fn new(normalized: &str, original: &str) -> Result<Self> {
let invalid = || Error::InvalidArgument {
arg: "lang",
reason: format!(
"invalid language code {original:?}: expected an ISO 639 code such as \"tha\" \
or \"sw-ke\""
),
};
if normalized.len() > MAX_TAG_LEN {
return Err(invalid());
}
let mut subtags = normalized.split('-');
let primary = subtags.next().ok_or_else(invalid)?;
if !(2..=3).contains(&primary.len()) || !primary.bytes().all(|b| b.is_ascii_lowercase()) {
return Err(invalid());
}
for subtag in subtags {
if !(2..=8).contains(&subtag.len())
|| !subtag
.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit())
{
return Err(invalid());
}
}
let mut bytes = [0u8; MAX_TAG_LEN];
bytes[..normalized.len()].copy_from_slice(normalized.as_bytes());
Ok(Self {
bytes,
len: normalized.len() as u8,
})
}
pub fn as_str(&self) -> &str {
std::str::from_utf8(&self.bytes[..usize::from(self.len)]).unwrap_or_default()
}
}
impl std::str::FromStr for Lang {
type Err = Error;
fn from_str(s: &str) -> Result<Self> {
Self::parse(s)
}
}
impl Lang {
pub fn parse(s: &str) -> Result<Self> {
let normalized = s.to_ascii_lowercase().replace('_', "-");
match normalized.as_str() {
"en" | "en-us" | "eng" => Ok(Self::EnUs),
"en-gb" | "en-uk" => Ok(Self::EnGb),
"es" | "es-es" | "es-mx" | "spa" => Ok(Self::Es),
"fr" | "fr-fr" | "fra" | "fre" => Ok(Self::Fr),
"ja" | "ja-jp" | "jpn" => Ok(Self::Ja),
"hi" | "hi-in" | "hin" => Ok(Self::Hi),
"it" | "it-it" | "ita" => Ok(Self::It),
"pt" | "pt-br" | "pt-pt" | "por" => Ok(Self::Pt),
"zh" | "zh-cn" | "zh-tw" | "zho" | "chi" | "cmn" => Ok(Self::Zh),
"ko" | "ko-kr" | "kor" => Ok(Self::Ko),
"ms" | "ms-my" | "msa" | "may" | "zlm" | "zsm" => Ok(Self::Ms),
other => LangTag::new(other, s).map(Self::Other),
}
}
pub fn bcp47(&self) -> &str {
match self {
Self::EnUs => "en-us",
Self::EnGb => "en-gb",
Self::Es => "es",
Self::Fr => "fr",
Self::Ja => "ja",
Self::Hi => "hi",
Self::It => "it",
Self::Pt => "pt",
Self::Zh => "cmn",
Self::Ko => "ko",
Self::Ms => "ms",
Self::Other(tag) => tag.as_str(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_common_forms() {
assert_eq!(Lang::parse("en").unwrap(), Lang::EnUs);
assert_eq!(Lang::parse("en-US").unwrap(), Lang::EnUs);
assert_eq!(Lang::parse("en_GB").unwrap(), Lang::EnGb);
assert_eq!(Lang::parse("zh-CN").unwrap(), Lang::Zh);
}
#[test]
fn maps_iso_639_3_codes_to_named_variants() {
assert_eq!(Lang::parse("eng").unwrap(), Lang::EnUs);
assert_eq!(Lang::parse("zlm").unwrap(), Lang::Ms);
assert_eq!(Lang::parse("cmn").unwrap(), Lang::Zh);
}
#[test]
fn keeps_other_well_formed_codes() {
let thai = Lang::parse("THA").unwrap();
assert_eq!(thai.bcp47(), "tha");
let swahili = Lang::parse("sw_KE").unwrap();
assert_eq!(swahili.bcp47(), "sw-ke");
}
#[test]
fn rejects_malformed_codes() {
for bad in ["", "e", "english", "e1", "en-", "en-x", "abc-toolongsubtag"] {
assert!(Lang::parse(bad).is_err(), "{bad:?} parsed");
}
}
#[test]
fn tag_round_trips_through_parse() {
for lang in [
Lang::EnUs,
Lang::EnGb,
Lang::Es,
Lang::Fr,
Lang::Ja,
Lang::Hi,
Lang::It,
Lang::Pt,
Lang::Zh,
Lang::Ko,
Lang::Ms,
Lang::parse("tha").unwrap(),
] {
assert_eq!(Lang::parse(lang.bcp47()).unwrap(), lang);
}
}
}