mod articles;
use std::fmt::{self, Display, Formatter, Write as _};
use std::str::FromStr;
macro_rules! languages {
($(
$(#[doc = $note:literal])*
$variant:ident, $name:literal,
[$code0:literal $(, $code:literal)*],
[$($article0:literal $(, $article:literal)*)?];
)+) => {
$(
#[doc = concat!(
"| ", $name,
" | `", $code0, "`", $(", `", $code, "`",)*
" | ", $("`", $article0, "`", $(", `", $article, "`",)*)? " |"
)]
)+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum Language {
$(
#[doc = concat!($name, ", code `", $code0, "`.")]
$(#[doc = $note])*
$variant,
)+
}
impl Language {
pub const ALL: &'static [Self] = &[$(Self::$variant),+];
#[must_use]
pub const fn name(self) -> &'static str {
match self {
$(Self::$variant => $name,)+
}
}
#[must_use]
pub const fn code(self) -> &'static str {
match self {
$(Self::$variant => $code0,)+
}
}
#[must_use]
pub const fn codes(self) -> &'static [&'static str] {
match self {
$(Self::$variant => &[$code0 $(, $code)*],)+
}
}
#[must_use]
pub const fn articles(self) -> &'static [&'static str] {
match self {
$(Self::$variant => &[$($article0 $(, $article)*)?],)+
}
}
}
};
}
languages! {
Afrikaans, "Afrikaans", ["af", "afr"], ["die", "'n"];
Breton, "Breton", ["br", "bre"], ["an", "al", "ar", "un", "ul", "ur", "eun", "eul", "eur"];
Catalan, "Catalan (Valencian)", ["ca", "cat"], ["el", "els", "la", "les", "l'", "un", "una"];
Welsh, "Welsh", ["cy", "cym", "wel"], ["y", "yr"];
Danish, "Danish", ["da", "dan"], ["de", "den", "det", "en", "et"];
German, "German", ["de", "deu", "ger"], ["der", "die", "das", "ein", "eine"];
English, "English", ["en", "eng"], ["a", "an", "the"];
Esperanto, "Esperanto", ["eo", "epo"], ["la"];
Spanish, "Spanish", ["es", "spa"], ["el", "la", "los", "las", "lo", "un", "una"];
French, "French", ["fr", "fra", "fre"], ["le", "la", "les", "l'", "un", "une"];
Irish, "Irish", ["ga", "gle"], ["an", "na"];
Galician, "Galician", ["gl", "glg"], ["o", "a", "os", "as", "un", "unha"];
Hungarian, "Hungarian", ["hu", "hun"], ["a", "az", "egy"];
Icelandic, "Icelandic", ["is", "isl", "ice"], ["hinn", "hin", "hið", "hinir", "hinar"];
Italian, "Italian", ["it", "ita"],
["il", "lo", "la", "l'", "i", "gli", "gl'", "le", "un", "uno", "una", "un'"];
Latin, "Latin", ["la", "lat"], [];
Luxembourgish, "Luxembourgish", ["lb", "ltz"],
["den", "de", "d'", "deen", "dat", "déi", "en", "e", "eng"];
Maltese, "Maltese", ["mt", "mlt"],
["il-", "l-", "iċ-", "id-", "in-", "ir-", "is-", "it-", "ix-", "iż-", "iz-", "ic-"];
Dutch, "Dutch (Flemish)", ["nl", "nld", "dut"], ["de", "het", "een", "eene", "'t", "'n"];
Norwegian, "Norwegian", ["no", "nor", "nb", "nob", "nn", "nno"],
["de", "dei", "den", "det", "e", "ei", "ein", "eit", "en", "et"];
Occitan, "Occitan (Provençal)", ["oc", "oci", "pro"],
["il", "l'", "la", "las", "le", "les", "lh", "lhi", "li", "lis", "lo", "los", "lou",
"lu", "lei", "leis", "un", "una", "uno", "uns", "us"];
Portuguese, "Portuguese", ["pt", "por"], ["o", "a", "os", "as", "um", "uma"];
Romanian, "Romanian (Moldavian)", ["ro", "ron", "rum", "mo", "mol"], ["un", "o"];
Albanian, "Albanian", ["sq", "sqi", "alb", "als", "aln", "aae", "aat"], [];
Swedish, "Swedish", ["sv", "swe"], ["de", "den", "det", "en", "ett"];
}
impl Language {
#[must_use]
pub fn strip_initial_article(self, title: &str) -> Option<&str> {
articles::strip(self, title)
}
pub fn from_optional(code: Option<&str>) -> Result<Option<Self>, UnknownLanguage> {
match code.map(str::trim) {
None | Some("") => Ok(None),
Some(code) => code.parse().map(Some),
}
}
pub const fn for_rule(
language: Option<Self>,
enabled: bool,
) -> Result<Option<Self>, MissingLanguage> {
match (enabled, language) {
(false, _) => Ok(None),
(true, Some(_)) => Ok(language),
(true, None) => Err(MissingLanguage),
}
}
}
impl FromStr for Language {
type Err = UnknownLanguage;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let trimmed = s.trim();
let unknown = || UnknownLanguage(trimmed.to_owned());
if !trimmed.is_ascii() {
return Err(unknown());
}
let primary = trimmed
.split(['-', '_', '.', '@'])
.next()
.unwrap_or_default();
Self::ALL
.iter()
.copied()
.find(|language| {
language
.codes()
.iter()
.any(|code| code.eq_ignore_ascii_case(primary))
})
.ok_or_else(unknown)
}
}
impl Display for Language {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
f.pad(self.code())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, derive_more::Error)]
pub struct UnknownLanguage(#[error(ignore)] String);
impl Display for UnknownLanguage {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
f.write_str("unknown language \"")?;
for c in self.0.chars() {
if c == '\'' {
f.write_char(c)?;
} else {
for escaped in c.escape_debug() {
f.write_char(escaped)?;
}
}
}
f.write_str("\" (expected one of: ")?;
for (i, language) in Language::ALL.iter().enumerate() {
if i > 0 {
f.write_str(", ")?;
}
f.write_str(language.code())?;
}
f.write_str(")")
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, derive_more::Display, derive_more::Error)]
#[display("a language is required")]
pub struct MissingLanguage;
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashSet;
use std::error::Error;
use Language as L;
#[test]
fn article_lists_are_pinned() {
let pinned: &[(Language, &str)] = &[
(L::Afrikaans, "die 'n"),
(L::Albanian, ""),
(L::Breton, "an al ar un ul ur eun eul eur"),
(L::Catalan, "el els la les l' un una"),
(L::Danish, "de den det en et"),
(L::Dutch, "de het een eene 't 'n"),
(L::English, "a an the"),
(L::Esperanto, "la"),
(L::French, "le la les l' un une"),
(L::Galician, "o a os as un unha"),
(L::German, "der die das ein eine"),
(L::Hungarian, "a az egy"),
(L::Icelandic, "hinn hin hið hinir hinar"),
(L::Irish, "an na"),
(L::Italian, "il lo la l' i gli gl' le un uno una un'"),
(L::Latin, ""),
(L::Luxembourgish, "den de d' deen dat déi en e eng"),
(L::Maltese, "il- l- iċ- id- in- ir- is- it- ix- iż- iz- ic-"),
(L::Norwegian, "de dei den det e ei ein eit en et"),
(
L::Occitan,
"il l' la las le les lh lhi li lis lo los lou lu lei leis un una uno uns us",
),
(L::Portuguese, "o a os as um uma"),
(L::Romanian, "un o"),
(L::Spanish, "el la los las lo un una"),
(L::Swedish, "de den det en ett"),
(L::Welsh, "y yr"),
];
assert_eq!(pinned.len(), L::ALL.len());
for &(language, list) in pinned {
let want: Vec<&str> = list.split_whitespace().collect();
assert_eq!(language.articles(), want.as_slice(), "{language}");
}
}
#[test]
fn names_and_codes_are_pinned() {
let pinned: &[(Language, &str, &str)] = &[
(L::Afrikaans, "Afrikaans", "af afr"),
(L::Albanian, "Albanian", "sq sqi alb als aln aae aat"),
(L::Breton, "Breton", "br bre"),
(L::Catalan, "Catalan (Valencian)", "ca cat"),
(L::Danish, "Danish", "da dan"),
(L::Dutch, "Dutch (Flemish)", "nl nld dut"),
(L::English, "English", "en eng"),
(L::Esperanto, "Esperanto", "eo epo"),
(L::French, "French", "fr fra fre"),
(L::Galician, "Galician", "gl glg"),
(L::German, "German", "de deu ger"),
(L::Hungarian, "Hungarian", "hu hun"),
(L::Icelandic, "Icelandic", "is isl ice"),
(L::Irish, "Irish", "ga gle"),
(L::Italian, "Italian", "it ita"),
(L::Latin, "Latin", "la lat"),
(L::Luxembourgish, "Luxembourgish", "lb ltz"),
(L::Maltese, "Maltese", "mt mlt"),
(L::Norwegian, "Norwegian", "no nor nb nob nn nno"),
(L::Occitan, "Occitan (Provençal)", "oc oci pro"),
(L::Portuguese, "Portuguese", "pt por"),
(L::Romanian, "Romanian (Moldavian)", "ro ron rum mo mol"),
(L::Spanish, "Spanish", "es spa"),
(L::Swedish, "Swedish", "sv swe"),
(L::Welsh, "Welsh", "cy cym wel"),
];
assert_eq!(pinned.len(), L::ALL.len());
for &(language, name, list) in pinned {
assert_eq!(language.name(), name, "{language}");
let want: Vec<&str> = list.split_whitespace().collect();
assert_eq!(language.codes(), want.as_slice(), "{language}");
}
}
#[test]
fn excluded_forms_are_not_listed() {
let excluded: &[(Language, &[&str])] = &[
(L::English, &["ye", "d'", "de"]),
(L::Catalan, &["en", "uns", "unes"]),
(L::Romanian, &["unui", "unei", "a", "al", "ai", "ale"]),
(
L::German,
&[
"des", "dem", "den", "einer", "eines", "einem", "einen", "'s",
],
),
(
L::Icelandic,
&[
"hina", "hinum", "hinni", "hinu", "hins", "hinnar", "hinna", "'r",
],
),
(L::Occitan, &["ih'"]),
(L::Spanish, &["unos", "unas"]),
(L::Portuguese, &["uns", "umas"]),
(L::Galician, &["uns", "unhas"]),
(L::Albanian, &["një", "nje", "nji", "njē"]),
(L::Luxembourgish, &["dem", "der", "engem", "enger"]),
];
for &(language, forms) in excluded {
for form in forms {
assert!(
!language.articles().contains(form),
"{language} lists {form}"
);
}
}
}
#[test]
fn table_invariants() {
let unique: HashSet<_> = L::ALL.iter().collect();
assert_eq!(unique.len(), L::ALL.len(), "ALL lists a language twice");
assert!(
L::ALL.is_sorted_by_key(|language| language.code()),
"ALL must be ordered by code"
);
let mut seen = HashSet::new();
for &language in L::ALL {
assert_eq!(language.codes().first(), Some(&language.code()));
assert_eq!(language.code().len(), 2, "{language}: not ISO 639-1");
assert_eq!(language.to_string(), language.code());
assert_eq!(format!("{language:>4}"), format!("{:>4}", language.code()));
for &code in language.codes() {
assert!(seen.insert(code), "{code} belongs to two languages");
assert!(
code.bytes().all(|b| b.is_ascii_lowercase()),
"{language}: {code:?} is not lowercase ASCII"
);
}
for &article in language.articles() {
assert_eq!(article, article.to_lowercase(), "{language} {article}");
assert!(!article.contains(char::is_whitespace));
assert!(
!article
.chars()
.any(|c| ('\u{300}'..='\u{36F}').contains(&c)),
"{language} {article} is not precomposed"
);
}
let mut articles = HashSet::new();
for &article in language.articles() {
assert!(articles.insert(article), "{language} lists {article} twice");
}
}
}
#[test]
fn every_code_resolves_in_any_case() {
for &language in L::ALL {
for &code in language.codes() {
assert_eq!(code.parse(), Ok(language), "{code}");
assert_eq!(code.to_uppercase().parse(), Ok(language), "{code}");
let mut mixed = code.to_owned();
mixed.get_mut(..1).map(str::make_ascii_uppercase);
assert_eq!(mixed.parse(), Ok(language), "{mixed}");
assert_eq!(format!(" {code}\t").parse(), Ok(language), "{code}");
}
}
}
#[test]
fn tags_and_locales_resolve() {
let cases = [
("pt-BR", L::Portuguese),
("ca-ES-valencia", L::Catalan),
("ca-valencia", L::Catalan),
("ca@valencia", L::Catalan),
("nb_NO", L::Norwegian),
("nn-NO", L::Norwegian),
("pt_BR.UTF-8", L::Portuguese),
("de.UTF-8", L::German),
("sv-s", L::Swedish),
("sv-SE", L::Swedish),
("mo", L::Romanian),
("\u{A0}en\u{3000}", L::English),
];
for (code, want) in cases {
assert_eq!(code.parse(), Ok(want), "{code}");
}
}
#[test]
fn bad_codes_are_errors() {
for code in [
"val",
"iri",
"gag",
"xx",
"",
" ",
"-",
"_en",
"énglish",
"en\u{301}",
"e n",
"en-é",
"ca@valència",
] {
let err = code.parse::<Language>().unwrap_err();
assert_eq!(err, UnknownLanguage(code.trim().to_owned()));
}
assert_eq!("eng-".parse(), Ok(L::English));
}
#[test]
fn error_message_lists_the_codes() {
let message = "XX".parse::<Language>().unwrap_err().to_string();
let codes: Vec<&str> = L::ALL.iter().map(|language| language.code()).collect();
assert_eq!(
message,
format!(
"unknown language \"XX\" (expected one of: {})",
codes.join(", ")
)
);
let escaped = UnknownLanguage("a\"b\n".to_owned()).to_string();
assert!(
escaped.starts_with(r#"unknown language "a\"b\n" ("#),
"{escaped}"
);
let apostrophe = UnknownLanguage("l'".to_owned()).to_string();
assert!(
apostrophe.starts_with(r#"unknown language "l'" ("#),
"{apostrophe}"
);
let combining = UnknownLanguage("en\u{301}x\u{301}".to_owned()).to_string();
assert!(
combining.starts_with(r#"unknown language "en\u{301}x\u{301}" ("#),
"{combining}"
);
}
#[test]
fn from_optional_treats_blank_as_absent() {
assert_eq!(L::from_optional(None), Ok(None));
assert_eq!(L::from_optional(Some("")), Ok(None));
assert_eq!(L::from_optional(Some(" \t ")), Ok(None));
assert_eq!(L::from_optional(Some(" EN ")), Ok(Some(L::English)));
assert_eq!(
L::from_optional(Some("xx")),
Err(UnknownLanguage("xx".to_owned()))
);
}
#[test]
fn only_languages_without_articles_have_none() {
let without: Vec<Language> = L::ALL
.iter()
.copied()
.filter(|language| language.articles().is_empty())
.collect();
assert_eq!(without, [L::Latin, L::Albanian]);
}
#[test]
fn for_rule_needs_a_language_only_when_on() {
for language in [None, Some(L::English)] {
assert_eq!(L::for_rule(language, false), Ok(None));
}
for &language in L::ALL {
assert_eq!(L::for_rule(Some(language), true), Ok(Some(language)));
}
assert_eq!(L::for_rule(None, true), Err(MissingLanguage));
assert_eq!(MissingLanguage.to_string(), "a language is required");
}
#[test]
fn errors_are_send_sync_and_std_error() {
fn assert_traits<T: Error + Send + Sync + 'static>() {}
assert_traits::<UnknownLanguage>();
assert_traits::<MissingLanguage>();
}
}