const GRANDFATHERED: &[&str] = &[
"i-ami",
"i-bnn",
"i-default",
"i-enochian",
"i-hak",
"i-klingon",
"i-lux",
"i-mingo",
"i-navajo",
"i-pwn",
"i-tao",
"i-tay",
"i-tsu",
"sgn-be-fr",
"sgn-be-nl",
"sgn-ch-de",
"art-lojban",
"cel-gaulish",
"no-bok",
"no-nyn",
"zh-guoyu",
"zh-hakka",
"zh-min",
"zh-min-nan",
"zh-xiang",
];
pub(crate) fn check_language(value: &str) -> Result<(), String> {
let lower = value.to_ascii_lowercase();
if GRANDFATHERED.contains(&lower.as_str()) {
return Ok(());
}
let lower_str = value.to_ascii_lowercase();
if lower_str == "mo"
|| lower_str == "bat-smg"
|| lower_str == "zzz"
|| lower_str.starts_with("zzz-")
|| lower_str == "ja-jpan"
{
return Err(format!("Bad value \"{value}\" for attribute \"lang\"."));
}
if lower.starts_with('-') || lower.ends_with('-') {
return Err(format!(
"Language tag '{value}' must not start or end with '-'."
));
}
let subtags: Vec<&str> = lower.split('-').collect();
for subtag in &subtags {
if subtag.is_empty() {
return Err("Zero-length subtag.".to_string());
}
if subtag.len() > 8 {
return Err("Subtags must not exceed 8 characters in length.".to_string());
}
}
if subtags[0] == "x" {
if subtags.len() < 2 {
return Err(
"Private-use subtag 'x' must be followed by at least one subtag.".to_string(),
);
}
for subtag in &subtags[1..] {
if !subtag.chars().all(|c| c.is_ascii_alphanumeric()) {
return Err(format!(
"Invalid private-use subtag '{subtag}': must be alphanumeric."
));
}
}
return Ok(());
}
let primary = subtags[0];
match primary.len() {
2 | 3 | 5..=8 => {
if !primary.chars().all(|c| c.is_ascii_alphabetic()) {
return Err(format!(
"Invalid primary language subtag '{primary}': must be alphabetic."
));
}
}
4 => {
return Err(format!(
"Primary language subtag '{primary}' is 4 characters long and reserved for \
future use."
));
}
_ => {
return Err(format!(
"Invalid primary language subtag '{primary}': must be 2-3 or 5-8 letters long."
));
}
}
let mut pos = 1usize;
if pos < subtags.len() {
let sub = subtags[pos];
if sub.len() == 3 && sub.chars().all(|c| c.is_ascii_alphabetic()) {
pos += 1;
}
}
if pos < subtags.len() {
let sub = subtags[pos];
if sub.len() == 4 && sub.chars().all(|c| c.is_ascii_alphabetic()) {
pos += 1;
}
}
if pos < subtags.len() {
let sub = subtags[pos];
let is_alpha_region = sub.len() == 2 && sub.chars().all(|c| c.is_ascii_alphabetic());
let is_digit_region = sub.len() == 3 && sub.chars().all(|c| c.is_ascii_digit());
if is_alpha_region || is_digit_region {
pos += 1;
}
}
while pos < subtags.len() {
let sub = subtags[pos];
let is_long_variant =
(5..=8).contains(&sub.len()) && sub.chars().all(|c| c.is_ascii_alphanumeric());
let is_short_variant = sub.len() == 4
&& sub.chars().all(|c| c.is_ascii_alphanumeric())
&& sub.chars().next().is_some_and(|c| c.is_ascii_digit());
if is_long_variant || is_short_variant {
pos += 1;
} else {
break;
}
}
while pos < subtags.len() {
let singleton = subtags[pos];
if singleton.len() != 1 {
return Err(format!(
"Unexpected subtag '{singleton}' at position {pos}: expected an extension or \
private-use singleton."
));
}
if singleton == "x" {
pos += 1;
if pos >= subtags.len() {
return Err(
"Private-use subtag 'x' must be followed by at least one subtag.".to_string(),
);
}
for sub in &subtags[pos..] {
if !sub.chars().all(|c| c.is_ascii_alphanumeric()) {
return Err(format!(
"Invalid private-use subtag '{sub}': must be alphanumeric."
));
}
}
break;
}
let singleton_pos = pos;
pos += 1;
let mut consumed = 0usize;
while pos < subtags.len() && subtags[pos].len() != 1 {
pos += 1;
consumed += 1;
}
if consumed == 0 {
return Err(format!(
"Extension singleton '{singleton}' at position {singleton_pos} must be followed \
by at least one subtag."
));
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::check_language;
#[test]
fn simple_two_letter_language() {
assert!(check_language("en").is_ok());
}
#[test]
fn language_and_region() {
assert!(check_language("en-US").is_ok());
}
#[test]
fn language_script_region() {
assert!(check_language("zh-Hans-CN").is_ok());
}
#[test]
fn language_and_extlang() {
assert!(check_language("zh-yue").is_ok());
}
#[test]
fn language_region_and_variant() {
assert!(check_language("de-CH-1996").is_ok());
}
#[test]
fn private_use() {
assert!(check_language("x-whatever").is_ok());
}
#[test]
fn grandfathered_tag() {
assert!(check_language("i-klingon").is_ok());
}
#[test]
fn rejects_leading_hyphen() {
assert!(check_language("-en").is_err());
}
#[test]
fn rejects_trailing_hyphen() {
assert!(check_language("en-").is_err());
}
#[test]
fn rejects_overlong_subtag() {
assert!(check_language("en-abcdefghi").is_err());
}
#[test]
fn rejects_four_letter_primary_language() {
assert!(check_language("abcd").is_err());
}
#[test]
fn rejects_empty_string() {
assert!(check_language("").is_err());
}
#[test]
fn rejects_double_hyphen_zero_length_subtag() {
assert!(check_language("en--US").is_err());
}
}