Files
craft-libs/crates/proof/src/lang.rs
T
Martin BallandClaude Sonnet 5.5 72e21ec7ca craft-proof: a variant after the region is looked up without the region first (ca-ES-valencia reads the ca-valencia pack) (review: FredApps)
One region_index/without_index helper in lang.rs serves region(), pack_lang::names and packs::locales_for; tests pin the sr-RS-ijekavsk names and the length cut of sr-Cyrl-RS-ijekavsk.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCYmv6UJmY2QcuhfXco
2026-11-03 21:40:25 +00:00

590 lines
30 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Proofing languages: which dictionary, grammar rules and hyphenation patterns apply to a run of
//! text. A run's language is its BCP 47 tag (`w:lang` in .docx, `CharProps::lang` in the model),
//! resolved with [`ProofLang::from_tag`].
/// A language WordCraft can proof, or [`ProofLang::Other`] for one it can't.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub enum ProofLang {
/// American English (`en`, `en-US`, and every region not listed for [`ProofLang::EnGb`], [`ProofLang::EnAu`] or
/// [`ProofLang::EnCa`]): SCOWL's en-US dictionary ([`crate::scowl`]), the English grammar rules and the
/// English hyphenation dictionary and patterns. British spellings (`colour`, `centre`,
/// `organise`) are flagged, with the American form suggested ([`crate::en_variants`]). Also
/// text with no language at all (WordCraft's default editing language is US English, like
/// Word's).
#[default]
En,
/// English with British spelling (`en-GB`, `en-NZ`, `en-IE`, `en-ZA`, `en-IN`…):
/// [`ProofLang::En`]'s tools, but American spellings (`color`, `center`, `analyze`) are
/// flagged and `-ize` and `-ise` are both right, as in Oxford spelling.
EnGb,
/// Australian English (`en-AU`): [`ProofLang::EnGb`]'s spelling policy, against SCOWL's own
/// en-AU dictionary, which knows Australian words (`billabong`, `brumby`, `larrikin`) and
/// spells some words the Australian way (`program`, `inquiry`).
EnAu,
/// Canadian English (`en-CA`): British `-our`, `-re` and `-ence` (`colour`, `centre`,
/// `defence`) with American `-ize` (`organize`), `tire` and `program`.
EnCa,
/// Polish (`pl`, `pl-PL`): the installed Hunspell dictionary (when there is one), the Polish grammar rules and the Polish
/// hyphenation patterns.
Pl,
/// Bulgarian (`bg`, `bg-BG`): hyphenation by the Bulgarian syllable rules ([`crate::bg`]), and
/// spelling when a Bulgarian Hunspell dictionary or `bg` pack is found. No grammar rules yet.
Bg,
/// Portuguese (`pt`, `pt-BR`; `pt-PT` is [`ProofLang::Pack`] `pt-pt`, with its own dictionary
/// and these grammar rules and patterns): the installed Hunspell dictionary (VERO and
/// friends, when there is one), the Portuguese grammar rules and the Portuguese hyphenation
/// patterns.
Pt,
/// Italian (`it`, `it-IT`, `it-CH`): the installed Hunspell dictionary (`it_IT`, when there
/// is one), the Italian grammar rules and the Italian hyphenation patterns.
It,
/// Croatian (`hr`, `hr-HR`)
Hr,
/// Turkish (`tr`, `tr-TR`, `tr-CY`): the installed Hunspell dictionary (when there is one),
/// Turkish grammar rules, spelling suggestions and Turkish syllable hyphenation rules.
Tr,
/// Russian (`ru`, `ru-RU`): the installed Hunspell dictionary (when there is one), the Russian
/// grammar rules and the Russian word-division rules.
Ru,
/// Czech (`cs`, `cs-CZ`): the installed Hunspell dictionary, dictionary-driven suggestions
/// and generic punctuation checks. Hyphenation keeps the fallback English patterns.
Cs,
/// Indonesian (`id`, `id-ID`; also the old `in` and ISO 639-2 `ind`): the installed Hunspell
/// `id_ID` dictionary, dictionary-driven suggestions and generic checks with Indonesian
/// messages. Hyphenation keeps the fallback English patterns.
Id,
/// Spanish of Spain (`es`, `es-ES`, and every region not listed for [`ProofLang::EsMx`],
/// [`ProofLang::Es419`] or [`ProofLang::EsUy`]; also ISO 639-2 `spa`): the installed
/// Hunspell `es_ES` dictionary (or a generic Spanish one), dictionary-driven suggestions and
/// generic checks with Spanish messages ([`crate::es`]). Hyphenation keeps the fallback
/// English patterns.
Es,
/// Mexican Spanish (`es-MX`): [`ProofLang::Es`]'s tools with the installed `es_MX`
/// dictionary, else a Latin American or generic Spanish one.
EsMx,
/// Latin American Spanish (`es-419`, and the Spanish of the Americas other than Mexico and
/// Uruguay: `es-AR`, `es-CO`, `es-US`…): [`ProofLang::Es`]'s tools with the installed `es_419`
/// dictionary, else a generic or another Latin American Spanish one.
Es419,
/// Uruguayan Spanish (`es-UY`): [`ProofLang::Es`]'s tools with the installed `es_UY`
/// dictionary (else the neighbouring Rioplatense `es_AR`, a Latin American or a generic one),
/// and the `vos` forms of verbs (`tenés`, `vení`, `decime`) accepted ([`crate::es::voseo`]).
EsUy,
/// German (`de`, `de-DE`, `de-AT`…; `de-CH` and `de-LI` are [`ProofLang::Pack`] `de-ch`, with
/// their own dictionary and these patterns): hyphenation by the German patterns (reformed
/// spelling), and spelling when a German Hunspell dictionary or `de` pack is found. No grammar
/// rules yet.
De,
/// Any other language, by its tag (`nl`, `fr-FR`, `sr-Latn`): spelling and suggestions from
/// its Hunspell dictionary (the dictionaries folder, an installed Language pack, the system's)
/// through the shared affix engine with default tuning, and hyphenation from its Language
/// pack. No grammar rules. Without a dictionary or pack it behaves exactly as
/// [`ProofLang::Other`]: no marks, English hyphenation fallback.
Pack(PackLocale),
/// A language without proofing tools here (`x-none`, `zxx`, `und`, a malformed tag). Like Word without
/// the language's proofing tools: no spelling or grammar marks, so its words are never
/// flagged as English misspellings. Hyphenation keeps using the English patterns, as before
/// languages were told apart.
Other,
}
impl ProofLang {
/// Every language with a spelling dictionary, in display order.
pub const PROOFED: [ProofLang; 16] = [
ProofLang::En,
ProofLang::EnGb,
ProofLang::EnAu,
ProofLang::EnCa,
ProofLang::Pl,
ProofLang::Pt,
ProofLang::It,
ProofLang::Hr,
ProofLang::Tr,
ProofLang::Ru,
ProofLang::Cs,
ProofLang::Id,
ProofLang::Es,
ProofLang::EsMx,
ProofLang::Es419,
ProofLang::EsUy,
];
/// The proofing language of a BCP 47 tag (`pl-PL`, `en_GB`, `PL`…). The primary subtag
/// counts, and for English and Spanish the region too ([`ProofLang::english`],
/// [`ProofLang::spanish`]). No tag (or an empty one) is English, WordCraft's default editing
/// language. Any other valid language tag is [`ProofLang::Pack`] with its [`PackLocale`];
/// `x-none`, `und`, `zxx`, `mul`, `mis` and malformed tags are [`ProofLang::Other`].
pub fn from_tag(tag: Option<&str>) -> ProofLang {
let Some(tag) = tag.map(str::trim).filter(|t| !t.is_empty()) else { return ProofLang::En };
let primary = tag.split(['-', '_', '.', '@']).next().unwrap_or("");
if primary.eq_ignore_ascii_case("en") || primary.eq_ignore_ascii_case("eng") {
ProofLang::english(tag)
} else if primary.eq_ignore_ascii_case("pl") || primary.eq_ignore_ascii_case("pol") {
ProofLang::Pl
} else if primary.eq_ignore_ascii_case("bg") || primary.eq_ignore_ascii_case("bul") {
ProofLang::Bg
} else if primary.eq_ignore_ascii_case("pt") || primary.eq_ignore_ascii_case("por") {
// European Portuguese spells differently: its own dictionary (`pt_PT`, a `pt-pt` pack).
if region(tag).is_some_and(|r| r.eq_ignore_ascii_case("PT")) {
PackLocale::new("pt-PT").map_or(ProofLang::Pt, ProofLang::Pack)
} else {
ProofLang::Pt
}
} else if primary.eq_ignore_ascii_case("it") || primary.eq_ignore_ascii_case("ita") {
ProofLang::It
} else if primary.eq_ignore_ascii_case("hr") || primary.eq_ignore_ascii_case("hrv") {
ProofLang::Hr
} else if primary.eq_ignore_ascii_case("tr") || primary.eq_ignore_ascii_case("tur") {
ProofLang::Tr
} else if primary.eq_ignore_ascii_case("ru") || primary.eq_ignore_ascii_case("rus") {
ProofLang::Ru
} else if primary.eq_ignore_ascii_case("cs") || primary.eq_ignore_ascii_case("ces") || primary.eq_ignore_ascii_case("cze") {
ProofLang::Cs
} else if primary.eq_ignore_ascii_case("id") || primary.eq_ignore_ascii_case("ind") || primary.eq_ignore_ascii_case("in") {
ProofLang::Id
} else if primary.eq_ignore_ascii_case("es") || primary.eq_ignore_ascii_case("spa") {
ProofLang::spanish(tag)
} else if ["de", "deu", "ger"].iter().any(|c| primary.eq_ignore_ascii_case(c)) {
// Swiss (and Liechtenstein) German spells without ß: its own dictionary (`de_CH`, a
// `de-ch` pack).
if region(tag).is_some_and(|r| r.eq_ignore_ascii_case("CH") || r.eq_ignore_ascii_case("LI")) {
PackLocale::new("de-CH").map_or(ProofLang::De, ProofLang::Pack)
} else {
ProofLang::De
}
} else if tag.split_once('@').is_some_and(|(_, m)| m.eq_ignore_ascii_case("latin")) {
// POSIX `sr_RS@latin`: the Latin-script language (`sr-latn`).
PackLocale::new(&format!("{primary}-latn")).map_or(ProofLang::Other, ProofLang::Pack)
} else {
PackLocale::new(tag).map_or(ProofLang::Other, ProofLang::Pack)
}
}
/// The English of an English tag, by its [`region`]: Canada is [`ProofLang::EnCa`], Australia
/// [`ProofLang::EnAu`], other regions that spell the British way are [`ProofLang::EnGb`], and the United States, any other
/// region and no region are [`ProofLang::En`].
fn english(tag: &str) -> ProofLang {
match region(tag) {
Some(r) if r.eq_ignore_ascii_case("CA") => ProofLang::EnCa,
Some(r) if r.eq_ignore_ascii_case("AU") => ProofLang::EnAu,
Some(r) if BRITISH_SPELLING.iter().any(|b| b.eq_ignore_ascii_case(r)) => ProofLang::EnGb,
_ => ProofLang::En,
}
}
/// The Spanish of a Spanish tag, by its [`region`]: Mexico is [`ProofLang::EsMx`], Uruguay
/// [`ProofLang::EsUy`], Latin America (`419`) and the other countries of the Americas are
/// [`ProofLang::Es419`], and Spain, any other region and no region are [`ProofLang::Es`].
fn spanish(tag: &str) -> ProofLang {
match region(tag) {
Some(r) if r.eq_ignore_ascii_case("MX") => ProofLang::EsMx,
Some(r) if r.eq_ignore_ascii_case("UY") => ProofLang::EsUy,
Some(r) if LATIN_AMERICA.iter().any(|l| l.eq_ignore_ascii_case(r)) => ProofLang::Es419,
_ => ProofLang::Es,
}
}
/// The built-in language whose hyphenation, grammar and messages a regional
/// [`ProofLang::Pack`] language shares: German for `de-ch`, Portuguese for `pt-pt` (their
/// spelling is their own). Any other language is itself.
pub(crate) fn built_in(self) -> ProofLang {
match self {
ProofLang::Pack(l) if l.primary() == "de" => ProofLang::De,
ProofLang::Pack(l) if l.primary() == "pt" => ProofLang::Pt,
other => other,
}
}
/// Is this one of the Englishes ([`ProofLang::En`], [`ProofLang::EnGb`], [`ProofLang::EnAu`],
/// [`ProofLang::EnCa`])?
pub fn is_english(self) -> bool {
matches!(self, ProofLang::En | ProofLang::EnGb | ProofLang::EnAu | ProofLang::EnCa)
}
/// Is this one of the Spanishes ([`ProofLang::Es`], [`ProofLang::EsMx`], [`ProofLang::Es419`],
/// [`ProofLang::EsUy`])?
pub fn is_spanish(self) -> bool {
matches!(self, ProofLang::Es | ProofLang::EsMx | ProofLang::Es419 | ProofLang::EsUy)
}
/// The primary language subtag (`en`, `pl`, `bg`, `tr`, `ru`, `cs`, `id`, `de`, `es`); empty for
/// [`ProofLang::Other`] and for a [`ProofLang::Pack`] language, whose locale isn't `'static`:
/// use [`ProofLang::tag`] wherever the code names the language (keys, settings, saved or
/// reported values), or every pack language would read the same. Kept `'static` so existing
/// callers (`PROOFED.map(ProofLang::code)`) don't break.
pub fn code(self) -> &'static str {
match self {
ProofLang::Pack(_) => "",
ProofLang::En | ProofLang::EnGb | ProofLang::EnAu | ProofLang::EnCa => "en",
ProofLang::Pl => "pl",
ProofLang::Bg => "bg",
ProofLang::Pt => "pt",
ProofLang::It => "it",
ProofLang::Hr => "hr",
ProofLang::Tr => "tr",
ProofLang::Ru => "ru",
ProofLang::Cs => "cs",
ProofLang::Id => "id",
ProofLang::Es | ProofLang::EsMx | ProofLang::Es419 | ProofLang::EsUy => "es",
ProofLang::De => "de",
ProofLang::Other => "",
}
}
/// The locale whose Hunspell dictionary (dictionaries folder, Language pack, system) spells
/// this language through the shared affix engine: a [`ProofLang::Pack`] language's own, and
/// German's and Bulgarian's, which have hyphenation (and Bulgarian rules) of their own but no
/// dictionary slot. `None` for the languages with a slot of their own, English and `Other`.
pub(crate) fn spelling_locale(self) -> Option<PackLocale> {
match self {
ProofLang::Pack(l) => Some(l),
ProofLang::De => PackLocale::new("de-DE"),
ProofLang::Bg => PackLocale::new("bg-BG"),
_ => None,
}
}
/// The code that names this language, for keys, settings and reported values: [`code`] for
/// the built-in languages (`en`, `es`, …), the locale for a [`ProofLang::Pack`] one (`nl`,
/// `sr-latn`), so no two pack languages share one; empty for [`ProofLang::Other`].
///
/// [`code`]: ProofLang::code
pub fn tag(self) -> std::borrow::Cow<'static, str> {
match self {
ProofLang::Pack(l) => std::borrow::Cow::Owned(l.as_str().to_string()),
other => std::borrow::Cow::Borrowed(other.code()),
}
}
/// The locale of a [`ProofLang::Pack`] language.
pub fn pack_locale(self) -> Option<PackLocale> {
if let ProofLang::Pack(l) = self { Some(l) } else { None }
}
/// Does WordCraft check spelling in this language? For a [`ProofLang::Pack`] language, German
/// and Bulgarian: is a dictionary for it available now (read on first use, cached until the dictionaries folder or
/// the packs change)?
pub fn is_proofed(self) -> bool {
match self.spelling_locale() {
Some(l) => crate::pack_lang::has_dictionary(l),
None => Self::PROOFED.contains(&self),
}
}
}
/// The locale of a [`ProofLang::Pack`] language: a lowercase BCP 47 tag (`nl`, `fr-fr`,
/// `sr-latn`) of at most [`PackLocale::MAX_LEN`] bytes, stored inline so [`ProofLang`] stays `Copy`.
#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct PackLocale {
len: u8,
bytes: [u8; PackLocale::MAX_LEN],
}
impl PackLocale {
/// Longest locale kept; a longer tag is cut at the subtag that would pass it (whatever the
/// subtag is: `sr-Cyrl-RS-ijekavsk` loses its variant, `sr-RS-ijekavsk` keeps it).
pub const MAX_LEN: usize = 15;
/// The locale of a language tag (`nl-NL`, `sr_Latn`, `fr-FR.UTF-8`): lowercased, `_` as `-`,
/// without a POSIX `.…`/`@…` tail or anything from a singleton on (`-x-…`, `-u-…`). `None` for
/// a tag that isn't a language (`x-none`, `i-…`, `und`, `zxx`, `mul`, `mis`, malformed ones).
pub fn new(tag: &str) -> Option<PackLocale> {
let tag = tag.trim().split(['.', '@']).next().unwrap_or("");
let mut parts = tag.split(['-', '_']);
let primary = parts.next()?.to_ascii_lowercase();
if !(2..=8).contains(&primary.len())
|| !primary.bytes().all(|b| b.is_ascii_lowercase())
|| ["und", "zxx", "mul", "mis"].contains(&primary.as_str())
{
return None;
}
let mut out = primary;
for p in parts {
if p.len() <= 1 || p.len() > 8 || !p.bytes().all(|b| b.is_ascii_alphanumeric()) || out.len() + 1 + p.len() > Self::MAX_LEN {
break;
}
out.push('-');
out.push_str(&p.to_ascii_lowercase());
}
let mut bytes = [0u8; Self::MAX_LEN];
for (b, c) in bytes.iter_mut().zip(out.bytes()) {
*b = c;
}
Some(PackLocale { len: u8::try_from(out.len()).ok()?, bytes })
}
/// The locale (`nl`, `fr-fr`).
pub fn as_str(&self) -> &str {
self.bytes.get(..usize::from(self.len)).and_then(|b| std::str::from_utf8(b).ok()).unwrap_or_default()
}
/// The primary language subtag (`fr`).
pub fn primary(&self) -> &str {
self.as_str().split('-').next().unwrap_or_default()
}
}
impl std::fmt::Debug for PackLocale {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "PackLocale({:?})", self.as_str())
}
}
impl std::fmt::Display for PackLocale {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
/// Is a subtag a script (four ASCII letters: `Latn`, `cyrl`)?
pub(crate) fn is_script(subtag: &str) -> bool {
subtag.len() == 4 && subtag.bytes().all(|b| b.is_ascii_alphabetic())
}
/// The script a language is written in when its tag names none, lowercase, for the languages
/// written in more than one (or whose packs may name it): Serbian is Cyrillic, Bosnian and
/// Croatian Latin… `None`: the language doesn't say, and a pack naming a script never serves it.
pub(crate) fn default_script(primary: &str) -> Option<&'static str> {
Some(match primary {
"sr" | "ru" | "uk" | "be" | "bg" | "mk" | "kk" | "ky" | "tg" | "mn" | "tt" | "ba" | "cv" | "os" | "ce" => "cyrl",
"bs" | "hr" | "sh" | "az" | "uz" | "tk" | "ms" | "ha" | "ro" | "sq" | "tr" | "ku" | "so" => "latn",
"zh" => "hans",
"pa" => "guru",
_ => return None,
})
}
/// The script of a tag (`sr-latn`, `sr_Latn`): the one it names, else its language's default.
pub(crate) fn effective_script(tag: &str) -> Option<String> {
let mut parts = tag.split(['-', '_']);
let primary = parts.next().unwrap_or_default().to_ascii_lowercase();
parts.find(|p| is_script(p)).map(str::to_ascii_lowercase).or_else(|| default_script(&primary).map(str::to_string))
}
/// The position of the region among a tag's subtags: the first two-letter or three-digit one
/// after the language (so a four-letter script is never one), before any singleton.
pub(crate) fn region_index<S: AsRef<str>>(parts: &[S]) -> Option<usize> {
parts.iter().enumerate().skip(1).take_while(|(_, p)| p.as_ref().len() != 1).find_map(|(i, p)| {
let p = p.as_ref();
((p.len() == 2 && p.bytes().all(|b| b.is_ascii_alphabetic())) || (p.len() == 3 && p.bytes().all(|b| b.is_ascii_digit()))).then_some(i)
})
}
/// `items` without the one at `index`.
pub(crate) fn without_index<T: Clone>(items: &[T], index: usize) -> Vec<T> {
items.iter().enumerate().filter(|(i, _)| *i != index).map(|(_, p)| p.clone()).collect()
}
/// The region of a tag: the first two-letter or three-digit subtag after the language and
/// script, before any singleton; a POSIX `.UTF-8` or `@…` tail is ignored.
pub(crate) fn region(tag: &str) -> Option<&str> {
let tag = tag.split(['.', '@']).next().unwrap_or("");
// Subtags after a singleton (`x-…` private use, `u-…` extensions) aren't regions.
let parts: Vec<&str> = tag.split(['-', '_']).collect();
region_index(&parts).and_then(|i| parts.get(i).copied())
}
/// Regions whose Spanish is Latin American ([`ProofLang::Es419`]): Latin America and the
/// Caribbean (`419`) and its parts (South America `005`, Central America `013`, the Caribbean
/// `029`), the Spanish-speaking countries of the Americas other than Mexico and Uruguay, and the
/// United States.
const LATIN_AMERICA: [&str; 22] =
["419", "005", "013", "029", "AR", "BO", "CL", "CO", "CR", "CU", "DO", "EC", "GT", "HN", "NI", "PA", "PE", "PR", "PY", "SV", "US", "VE"];
/// Regions whose English uses British spelling ([`ProofLang::EnGb`]; Australia has its own,
/// [`ProofLang::EnAu`]): the United Kingdom (and the non-standard `UK`), Ireland, New Zealand, South Africa, India and other
/// Commonwealth countries, the Crown dependencies and Europe (`150`).
const BRITISH_SPELLING: [&str; 25] = [
"GB", "UK", "IE", "NZ", "ZA", "IN", "SG", "HK", "MY", "MT", "CY", "GI", "IM", "JE", "GG", "JM", "TT", "BS", "BB", "BZ", "KE", "NG", "GH", "PK",
"150",
];
/// A BCP 47 tag in canonical case (`pl-PL`, `en-GB`, `zh-Hant-TW`, `x-none`), or `None` when
/// `tag` isn't shaped like one: 1–35 ASCII letters, digits and `-`/`_` separators, starting with
/// a 2–8 letter language (or `x`/`i` for private and grandfathered tags). `_` becomes `-`.
pub fn normalize_tag(tag: &str) -> Option<String> {
let tag = tag.trim();
if tag.is_empty() || tag.len() > 35 || !tag.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') {
return None;
}
let parts: Vec<&str> = tag.split(['-', '_']).collect();
if parts.iter().any(|p| p.is_empty() || p.len() > 8) {
return None;
}
let first = parts.first()?;
let language = (2..=8).contains(&first.len()) && first.chars().all(|c| c.is_ascii_alphabetic());
if !language && !first.eq_ignore_ascii_case("x") && !first.eq_ignore_ascii_case("i") {
return None;
}
let letters = |p: &str| p.chars().all(|c| c.is_ascii_alphabetic());
// After a singleton (`x-…` private use, `u-…` extensions) subtags are lower case.
let singleton = parts.iter().position(|p| p.len() == 1).unwrap_or(usize::MAX);
let out: Vec<String> = parts
.iter()
.enumerate()
.map(|(i, p)| {
if i >= singleton {
p.to_ascii_lowercase()
} else if i > 0 && p.len() == 2 && letters(p) {
p.to_ascii_uppercase() // region
} else if i > 0 && p.len() == 4 && letters(p) {
let lower = p.to_ascii_lowercase();
let mut c = lower.chars();
c.next().map(|f| f.to_ascii_uppercase().to_string() + c.as_str()).unwrap_or_default() // script
} else {
p.to_ascii_lowercase()
}
})
.collect();
Some(out.join("-"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tags_normalize() {
assert_eq!(normalize_tag("PL-pl").as_deref(), Some("pl-PL"));
assert_eq!(normalize_tag(" pl_pl ").as_deref(), Some("pl-PL"));
assert_eq!(normalize_tag("pl").as_deref(), Some("pl"));
assert_eq!(normalize_tag("zh-hant-tw").as_deref(), Some("zh-Hant-TW"));
assert_eq!(normalize_tag("es-419").as_deref(), Some("es-419"));
assert_eq!(normalize_tag("x-none").as_deref(), Some("x-none"));
assert_eq!(normalize_tag("de-DE-u-co-PHONEBK").as_deref(), Some("de-DE-u-co-phonebk"));
for bad in ["", " ", "p", "1pl", "pl--PL", "pl PL", "pl-PL;", "polski język", "-pl", "pl-", "abcdefghi", &"a".repeat(40)] {
assert_eq!(normalize_tag(bad), None, "{bad:?}");
}
}
fn assert_tags(tags: &[&str], expected: ProofLang) {
for tag in tags {
assert_eq!(ProofLang::from_tag(Some(tag)), expected, "{tag}");
}
}
#[test]
fn tags_resolve_primary_languages() {
assert_tags(&["pl", "pl-PL", "PL-pl", "pl_PL", "pl_PL.UTF-8", " pl-PL ", "pol", "pl@euro", "pl.UTF-8"], ProofLang::Pl);
// craft-libs#482 review: a POSIX charset or modifier with no region.
assert_tags(&["en.UTF-8", "en@euro"], ProofLang::En);
assert_tags(&["de.UTF-8"], ProofLang::De);
let sr_latn = ProofLang::Pack(PackLocale::new("sr-latn").unwrap());
assert_tags(&["sr@latin", "sr_RS@latin", "sr_RS.UTF-8@latin", "sr-Latn"], sr_latn);
assert_tags(&["sr", "sr@cyrillic"], ProofLang::Pack(PackLocale::new("sr").unwrap()));
assert_tags(&["pt", "pt-BR", "PT_br", "pt-BR.UTF-8", "pt-AO", "por"], ProofLang::Pt);
let pt_pt = ProofLang::Pack(PackLocale::new("pt-pt").unwrap());
assert_tags(&[" pt-PT ", "pt_PT.UTF-8", "por-PT"], pt_pt);
assert_eq!((pt_pt.built_in(), ProofLang::Pt.built_in()), (ProofLang::Pt, ProofLang::Pt));
assert_tags(&["it", "it-IT", "IT_it", "it-CH", "it-CH.UTF-8", " it-IT ", "ita"], ProofLang::It);
assert_tags(&["cs", "cs-CZ", "CS-cz", "cs_CZ", " ces ", "CES", "cze", "CZE"], ProofLang::Cs);
assert_tags(&["id", "id-ID", "ID-id", "id_ID", "id_ID.UTF-8", " id-ID ", "ind", "in", "in_ID"], ProofLang::Id);
assert_eq!(ProofLang::Cs.code(), "cs");
assert!(ProofLang::Cs.is_proofed() && ProofLang::PROOFED.contains(&ProofLang::Cs));
assert_eq!(ProofLang::Id.code(), "id");
assert!(ProofLang::Id.is_proofed() && ProofLang::PROOFED.contains(&ProofLang::Id));
}
#[test]
fn tags_resolve_english_regions() {
assert_tags(&["en", "en-US", "EN_us", "en-Latn-US", "en-PH", "en-001", "eng", "en_US.UTF-8", "en-x-gb"], ProofLang::En);
assert_tags(
&["en-GB", "EN_gb", "en_GB.UTF-8", "en-NZ", "en-IE", "en-ZA", "en-IN", "en-Latn-GB", "en-UK", "en-150", "eng-GB", "en_GB@euro"],
ProofLang::EnGb,
);
assert_tags(&["en-CA", "en_CA", "EN-ca", "en_CA.UTF-8", "en-Latn-CA"], ProofLang::EnCa);
assert_tags(&["en-AU", "en_AU", "EN-au", "en_AU.UTF-8", "en-Latn-AU"], ProofLang::EnAu);
for language in [ProofLang::En, ProofLang::EnGb, ProofLang::EnAu, ProofLang::EnCa] {
assert!(language.is_english() && language.is_proofed() && ProofLang::PROOFED.contains(&language), "{language:?}");
assert_eq!(language.code(), "en");
}
assert!(!ProofLang::Pl.is_english() && !ProofLang::Other.is_english());
// The region is the first two-letter or three-digit subtag: a script or variant isn't one.
assert_eq!(ProofLang::from_tag(Some("en-Brai")), ProofLang::En);
assert_eq!(ProofLang::from_tag(Some("en-GB-oxendict")), ProofLang::EnGb);
}
#[test]
fn tags_resolve_additional_locales_and_defaults() {
assert_tags(&["bg", "bg-BG", "BG_bg", "bg_BG.UTF-8", "bul"], ProofLang::Bg);
assert_tags(&["tr", "tr-TR", "tr-CY", "TR_tr", "tur"], ProofLang::Tr);
assert_tags(&["de", "de-DE", "DE_at", "deu", "ger"], ProofLang::De);
let de_ch = ProofLang::Pack(PackLocale::new("de-ch").unwrap());
assert_tags(&["de-CH", "de_CH.UTF-8", "de-LI"], de_ch);
assert_eq!(de_ch.built_in(), ProofLang::De);
assert_tags(&["ru", "ru-RU", "RU-ru", "ru_RU", "ru_RU.UTF-8", " ru-RU ", "rus"], ProofLang::Ru);
// German and Bulgarian are proofed when a dictionary is found (craft-libs#485 review: an
// installed hunspell-de-de must not fail this test), so only their locales are fixed here.
assert_eq!(ProofLang::De.spelling_locale(), PackLocale::new("de-de"));
assert_eq!(ProofLang::Bg.spelling_locale(), PackLocale::new("bg-bg"));
assert!(!ProofLang::PROOFED.contains(&ProofLang::De) && !ProofLang::PROOFED.contains(&ProofLang::Bg));
assert_eq!(ProofLang::Bg.code(), "bg");
assert!(ProofLang::Pt.is_proofed() && !ProofLang::Other.is_proofed());
assert!(ProofLang::Tr.is_proofed());
assert!(ProofLang::Ru.is_proofed());
assert_eq!(ProofLang::Ru.code(), "ru");
assert!(ProofLang::PROOFED.contains(&ProofLang::Ru));
assert_eq!(ProofLang::Pl.code(), "pl");
assert_eq!(ProofLang::Pt.code(), "pt");
assert!(ProofLang::It.is_proofed());
assert_eq!(ProofLang::It.code(), "it");
assert!(ProofLang::Hr.is_proofed());
assert_eq!(ProofLang::Hr.code(), "hr");
assert_eq!(ProofLang::Tr.code(), "tr");
assert!(ProofLang::PROOFED.contains(&ProofLang::Pt), "every proofed language is listed");
assert_eq!(ProofLang::from_tag(None), ProofLang::En);
assert_eq!(ProofLang::from_tag(Some("")), ProofLang::En);
assert_eq!(ProofLang::from_tag(Some(" ")), ProofLang::En);
assert!(ProofLang::Pl.is_proofed() && !ProofLang::Other.is_proofed());
}
/// Two pack languages never share a key: `tag()` is the locale, while `code()` is empty.
#[test]
fn tag_names_each_pack_language() {
let (nl, fr) = (ProofLang::from_tag(Some("nl")), ProofLang::from_tag(Some("fr-FR")));
assert_eq!((nl.code(), fr.code()), ("", ""));
assert_eq!((nl.tag().as_ref(), fr.tag().as_ref()), ("nl", "fr-fr"));
for l in ProofLang::PROOFED {
assert_eq!(l.tag(), l.code());
}
assert_eq!(ProofLang::Other.tag(), "");
}
#[test]
fn tags_reject_unknown_or_partial_language_codes() {
// A partial code is another language, never the one it starts like.
for tag in ["fr-FR", "dex", "ja-JP", "plx", "bgx", "trx", "csx", "idx", "inx", "esx", "spax"] {
let lang = ProofLang::from_tag(Some(tag));
assert_eq!(lang.pack_locale().map(|l| l.to_string()), Some(tag.to_ascii_lowercase()), "{tag}");
assert_eq!(lang.code(), "", "{tag}: a pack language's locale is pack_locale()");
assert_eq!(lang.tag(), lang.pack_locale().map(|l| l.as_str().to_string()).unwrap_or_default(), "{tag}");
}
assert_tags(&["x-none", "e", "-", "und", "zxx", "i-klingon"], ProofLang::Other);
}
/// #3360, #3553: Spanish is proofed, by region: Spain (and no region), Mexico, Uruguay, and
/// Latin America with the other countries of the Americas.
#[test]
fn spanish_tags_resolve_by_region() {
for t in ["es", "es-ES", "ES_es", "es_ES.UTF-8", " es-ES ", "spa", "es-Latn-ES", "es-GQ", "es-PH", "es-x-mx", "es_ES@euro"] {
assert_eq!(ProofLang::from_tag(Some(t)), ProofLang::Es, "{t}");
}
for t in ["es-MX", "es_MX", "ES-mx", "es_MX.UTF-8", "spa-MX", "es-Latn-MX"] {
assert_eq!(ProofLang::from_tag(Some(t)), ProofLang::EsMx, "{t}");
}
for t in ["es-419", "es_419", "es-AR", "es-CO", "es_CL.UTF-8", "es-US", "ES-ve", "es-PR", "es-Latn-419", "es-005", "spa-PE"] {
assert_eq!(ProofLang::from_tag(Some(t)), ProofLang::Es419, "{t}");
}
for t in ["es-UY", "es_UY", "ES-uy", "es_UY.UTF-8", "spa-UY", "es-Latn-UY"] {
assert_eq!(ProofLang::from_tag(Some(t)), ProofLang::EsUy, "{t}");
}
for l in [ProofLang::Es, ProofLang::EsMx, ProofLang::Es419, ProofLang::EsUy] {
assert!(l.is_spanish() && l.is_proofed() && ProofLang::PROOFED.contains(&l), "{l:?}");
assert!(!l.is_english());
assert_eq!(l.code(), "es");
}
assert!(!ProofLang::En.is_spanish() && !ProofLang::Pt.is_spanish() && !ProofLang::Other.is_spanish());
assert_eq!(normalize_tag("ES_mx").as_deref(), Some("es-MX"));
}
}