csan_core/catalog/language.rs
1//! The language of a work, for csan's language-dependent filing rules.
2//!
3//! Filing rules that depend on the language of the title are opt-in and
4//! keyed by a [`Language`], which lists the supported languages and their
5//! codes and parses an ISO 639 code, a BCP 47 tag or a POSIX locale.
6//! Frontends that take a language and an on/off switch per rule convert them
7//! with [`Language::from_optional`] and [`Language::for_rule`].
8//!
9//! # Initial articles
10//!
11//! Library filing practice ignores a title's initial article. Cutter's
12//! _Rules for a Dictionary Catalogue_ say to "take account of every word
13//! except initial articles", and LC's shelflisting manual (G 100 §9) says to
14//! "ignore initial articles in the nominative case". So _A Christmas Carol_
15//! files under _Christmas_. [`Language::strip_initial_article`] removes the
16//! article, and the table in [`Language`]'s documentation lists each
17//! language's articles. A title is filed without it through
18//! [`Opus::with_article_language`](crate::Opus::with_article_language) (or the
19//! same method on [`Attribution`](crate::Attribution)). Author names are
20//! never stripped.
21//!
22//! A word that is an article in one language is an ordinary word in another
23//! (English _Die Hard_), which is why the rule is keyed by the title's
24//! language.
25//!
26//! The lists start from MARC 21 Appendix F's forms, nominative only (G 100
27//! §9), and depart from it where the sources do:
28//! - added: Breton (the list the JSC approved for RDA in 2006),
29//! Luxembourgish (its grammar's nominative forms, as the official
30//! orthography spells them), and standard forms that fill a gap in a
31//! paradigm Appendix F otherwise gives (Galician _os_ beside _as_, _un_
32//! beside _unha_; Provençal _lei_, _leis_; the assimilated Maltese forms;
33//! Irish without the hyphen);
34//! - left out: forms G 100 or the grammars show aren't initial articles
35//! (Catalan's personal _en_, Romanian's genitival _a_, _al_, and
36//! Albanian's _njē_, as G 100 §9 lists Albanian as having no initial
37//! articles), forms with no grammatical basis (Icelandic _'r_, Provençal
38//! _ih'_), and archaic or dialect forms that are mostly other words
39//! (English _ye_, _d'_, _de_; German _'s_, usually the pronoun _es_).
40//!
41//! Appendix F gives no plural indefinite for Spanish, Portuguese, Catalan,
42//! Galician or French (no _des_), only for Occitan (_uns_, _us_). That
43//! omission is taken as deliberate, so _unos_, _unas_, _uns_, _umas_, _unes_
44//! and _unhas_ aren't added, while Occitan's stay. Numerals that are also
45//! articles stay in the lists, so they are stripped, because G 100 makes "no
46//! attempt … to differentiate cardinal numbers from articles". Albanian and
47//! Latin, which G 100 §9 lists as having no initial articles, have none.
48//!
49//! Known false positives, which are the reason stripping is opt-in:
50//! - a letter used as a word (_A to Z drug facts_);
51//! - pronouns (_Lo dijo_, _O iubesc_, _Das ist…_);
52//! - articles that are part of a name (_El Cid_, _Los Angeles_), which
53//! G 100 §10 keeps;
54//! - the Irish interrogative _An_, and Irish loanwords that begin with `t`
55//! or `h` (_An tsunami_, _na hataí_).
56//!
57//! Known false negatives:
58//! - all-caps Irish mutation prefixes;
59//! - the oblique-case articles (German, Icelandic, Luxembourgish, Romanian),
60//! deliberately, per G 100's nominative rule (_Dem Andenken…_ files under
61//! _Dem_), and the plural indefinites above;
62//! - articles written in other Unicode forms: decomposed (NFD) or
63//! compatibility forms such as fullwidth letters are not folded;
64//! - an invisible format character (U+200B, U+00AD, U+FEFF) between an
65//! article and its space.
66
67mod articles;
68
69use std::fmt::{self, Display, Formatter, Write as _};
70use std::str::FromStr;
71
72/// Generates [`Language`] from one row per language, so the enum,
73/// [`Language::ALL`], the lookups and the table in the enum's
74/// documentation can't disagree.
75///
76/// A row holds optional notes for the variant's documentation, the variant,
77/// its English name, its codes (canonical first) and its articles. The
78/// variant's documentation starts with its name and canonical code, so those
79/// can't drift either. Rows go in code order, which is the order of `ALL`.
80/// rustfmt doesn't format these rows; they are aligned by hand.
81macro_rules! languages {
82 ($(
83 $(#[doc = $note:literal])*
84 $variant:ident, $name:literal,
85 [$code0:literal $(, $code:literal)*],
86 [$($article0:literal $(, $article:literal)*)?];
87 )+) => {
88 /// The language of a work, for csan's language-dependent filing rules.
89 ///
90 /// Parse one from an ISO 639 code, a BCP 47 tag or a POSIX locale with
91 /// [`FromStr`] (`"pt-BR".parse()`), or from an optional user input with
92 /// [`Language::from_optional`]. It displays as its canonical code.
93 /// [`Language::strip_initial_article`] removes a title's initial
94 /// article in that language; the table gives each language's codes and
95 /// articles.
96 ///
97 /// | Language | Codes | Articles |
98 /// | -------- | ----- | -------- |
99 $(
100 #[doc = concat!(
101 "| ", $name,
102 " | `", $code0, "`", $(", `", $code, "`",)*
103 " | ", $("`", $article0, "`", $(", `", $article, "`",)*)? " |"
104 )]
105 )+
106 ///
107 /// An empty Articles cell means the language has no initial articles:
108 /// its codes are accepted, and nothing is stripped.
109 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
110 #[non_exhaustive]
111 pub enum Language {
112 $(
113 #[doc = concat!($name, ", code `", $code0, "`.")]
114 $(#[doc = $note])*
115 $variant,
116 )+
117 }
118
119 impl Language {
120 /// Every supported language, ordered by code.
121 pub const ALL: &'static [Self] = &[$(Self::$variant),+];
122
123 /// The English name, e.g. `"Afrikaans"`, `"Catalan (Valencian)"`.
124 #[must_use]
125 pub const fn name(self) -> &'static str {
126 match self {
127 $(Self::$variant => $name,)+
128 }
129 }
130
131 /// The canonical code: the first of [`codes`](Self::codes), the
132 /// ISO 639-1 code (e.g. `"en"`). It is also what [`Display`]
133 /// prints.
134 #[must_use]
135 pub const fn code(self) -> &'static str {
136 match self {
137 $(Self::$variant => $code0,)+
138 }
139 }
140
141 /// Every accepted code, lowercase, canonical first, e.g.
142 /// `["nl", "nld", "dut"]`.
143 #[must_use]
144 pub const fn codes(self) -> &'static [&'static str] {
145 match self {
146 $(Self::$variant => &[$code0 $(, $code)*],)+
147 }
148 }
149
150 /// The articles recognized at the start of a title: lowercase,
151 /// precomposed (NFC), as written. Elided forms end in an
152 /// apostrophe (`l'`) and joined forms in a hyphen (Maltese `il-`).
153 /// Irish returns `["an", "na"]`; the `t`/`h` prefixes that follow
154 /// them are a rule, not list entries. Albanian and Latin return
155 /// `[]`.
156 #[must_use]
157 pub const fn articles(self) -> &'static [&'static str] {
158 match self {
159 $(Self::$variant => &[$($article0 $(, $article)*)?],)+
160 }
161 }
162 }
163 };
164}
165
166languages! {
167 Afrikaans, "Afrikaans", ["af", "afr"], ["die", "'n"];
168 Breton, "Breton", ["br", "bre"], ["an", "al", "ar", "un", "ul", "ur", "eun", "eul", "eur"];
169 Catalan, "Catalan (Valencian)", ["ca", "cat"], ["el", "els", "la", "les", "l'", "un", "una"];
170 Welsh, "Welsh", ["cy", "cym", "wel"], ["y", "yr"];
171 Danish, "Danish", ["da", "dan"], ["de", "den", "det", "en", "et"];
172 German, "German", ["de", "deu", "ger"], ["der", "die", "das", "ein", "eine"];
173 English, "English", ["en", "eng"], ["a", "an", "the"];
174 Esperanto, "Esperanto", ["eo", "epo"], ["la"];
175 Spanish, "Spanish", ["es", "spa"], ["el", "la", "los", "las", "lo", "un", "una"];
176 French, "French", ["fr", "fra", "fre"], ["le", "la", "les", "l'", "un", "une"];
177 /// Its articles may be followed by a `t` or `h` mutation prefix, which is
178 /// disregarded with them.
179 Irish, "Irish", ["ga", "gle"], ["an", "na"];
180 Galician, "Galician", ["gl", "glg"], ["o", "a", "os", "as", "un", "unha"];
181 Hungarian, "Hungarian", ["hu", "hun"], ["a", "az", "egy"];
182 Icelandic, "Icelandic", ["is", "isl", "ice"], ["hinn", "hin", "hið", "hinir", "hinar"];
183 Italian, "Italian", ["it", "ita"],
184 ["il", "lo", "la", "l'", "i", "gli", "gl'", "le", "un", "uno", "una", "un'"];
185 /// It has no articles (LC G 100 §9): its codes are accepted, and nothing
186 /// is stripped.
187 Latin, "Latin", ["la", "lat"], [];
188 Luxembourgish, "Luxembourgish", ["lb", "ltz"],
189 ["den", "de", "d'", "deen", "dat", "déi", "en", "e", "eng"];
190 /// Its article is joined to the noun by a hyphen.
191 Maltese, "Maltese", ["mt", "mlt"],
192 ["il-", "l-", "iċ-", "id-", "in-", "ir-", "is-", "it-", "ix-", "iż-", "iz-", "ic-"];
193 Dutch, "Dutch (Flemish)", ["nl", "nld", "dut"], ["de", "het", "een", "eene", "'t", "'n"];
194 /// Bokmål and Nynorsk, in one list.
195 Norwegian, "Norwegian", ["no", "nor", "nb", "nob", "nn", "nno"],
196 ["de", "dei", "den", "det", "e", "ei", "ein", "eit", "en", "et"];
197 Occitan, "Occitan (Provençal)", ["oc", "oci", "pro"],
198 ["il", "l'", "la", "las", "le", "les", "lh", "lhi", "li", "lis", "lo", "los", "lou",
199 "lu", "lei", "leis", "un", "una", "uno", "uns", "us"];
200 Portuguese, "Portuguese", ["pt", "por"], ["o", "a", "os", "as", "um", "uma"];
201 Romanian, "Romanian (Moldavian)", ["ro", "ron", "rum", "mo", "mol"], ["un", "o"];
202 /// It has no initial articles (LC G 100 §9): its codes are accepted, and
203 /// nothing is stripped.
204 Albanian, "Albanian", ["sq", "sqi", "alb", "als", "aln", "aae", "aat"], [];
205 Swedish, "Swedish", ["sv", "swe"], ["de", "den", "det", "en", "ett"];
206}
207
208impl Language {
209 /// `title` without its initial article, when it starts with one that this
210 /// language recognizes and a letter or ASCII digit follows; otherwise
211 /// `None`, including for a title that is only an article. The result is a
212 /// subslice of `title`, so nothing is allocated.
213 ///
214 /// The article may follow anything but a letter or ASCII digit: spaces,
215 /// punctuation, symbols (`"¿El otoño?"`, `"'t Hooft"` in quotes). A word
216 /// article must then be followed by whitespace (`"The, Bear"` keeps its
217 /// _The_), an elided one by anything (`"L'amour"`, `"L' amour"`), and a
218 /// joined one by a non-space (`"il-Kantilena"`). The result starts at the
219 /// first letter or ASCII digit after it, since MARC counts the spaces and
220 /// punctuation around an initial article as nonfiling too. Letters match
221 /// regardless of case but with their diacritics, an apostrophe matches its
222 /// typographic variants, and a hyphen its Unicode forms. Irish also loses
223 /// the `t`/`h` mutation prefix after its article. Only one article is
224 /// removed.
225 ///
226 /// Three limits follow from these rules. A digit other than an ASCII one
227 /// doesn't end the leading run, so `"1 The Bear"` (a fullwidth one)
228 /// loses its _The_. An invisible format character between an article and
229 /// its space (U+200B, U+00AD, U+FEFF) hides the article, so nothing is
230 /// stripped. And an article in another Unicode form (decomposed, or
231 /// fullwidth `"The"`) doesn't match: there is no normalization.
232 ///
233 /// # Examples
234 /// ```
235 /// use csan_core::Language;
236 ///
237 /// let english = Language::English;
238 /// assert_eq!(english.strip_initial_article("The Bear"), Some("Bear"));
239 /// assert_eq!(english.strip_initial_article("\"The 100 best\""), Some("100 best\""));
240 /// assert_eq!(english.strip_initial_article("Theory"), None);
241 /// assert_eq!(english.strip_initial_article("The"), None);
242 ///
243 /// let french = Language::French;
244 /// assert_eq!(french.strip_initial_article("L’Étranger"), Some("Étranger"));
245 /// let irish = Language::Irish;
246 /// assert_eq!(irish.strip_initial_article("An tUasal"), Some("Uasal"));
247 /// ```
248 #[must_use]
249 pub fn strip_initial_article(self, title: &str) -> Option<&str> {
250 articles::strip(self, title)
251 }
252
253 /// The front door for frontends, like
254 /// [`Attribution::from_parts`](crate::Attribution::from_parts): `None` and
255 /// blank strings mean no language (`Ok(None)`), and anything else parses
256 /// with [`FromStr`].
257 ///
258 /// # Errors
259 /// Returns [`UnknownLanguage`] for a non-blank code that no
260 /// supported language uses.
261 ///
262 /// # Examples
263 /// ```
264 /// use csan_core::Language;
265 ///
266 /// assert_eq!(Language::from_optional(None), Ok(None));
267 /// assert_eq!(Language::from_optional(Some(" ")), Ok(None));
268 /// assert_eq!(
269 /// Language::from_optional(Some("pt-BR")),
270 /// Ok(Some(Language::Portuguese))
271 /// );
272 /// assert!(Language::from_optional(Some("xx")).is_err());
273 /// ```
274 pub fn from_optional(code: Option<&str>) -> Result<Option<Self>, UnknownLanguage> {
275 match code.map(str::trim) {
276 None | Some("") => Ok(None),
277 Some(code) => code.parse().map(Some),
278 }
279 }
280
281 /// The language that a rule applies with, for frontends that take a
282 /// language and an on/off switch per rule (the CLI's `--lang` and
283 /// `--strip-article`): `Ok(None)` when the switch is off, and the language
284 /// when it is on.
285 ///
286 /// A language with the switch off is not an error, so that a frontend can
287 /// set the language once (Typst's `cutter-sanborn.with(lang: "pt")`, a
288 /// shell alias) and turn rules on per call.
289 ///
290 /// # Errors
291 /// Returns [`MissingLanguage`] when the switch is on and no language is
292 /// given.
293 ///
294 /// # Examples
295 /// ```
296 /// use csan_core::{Language, MissingLanguage};
297 ///
298 /// let english = Some(Language::English);
299 /// assert_eq!(Language::for_rule(english, true), Ok(english));
300 /// assert_eq!(Language::for_rule(english, false), Ok(None));
301 /// assert_eq!(Language::for_rule(None, false), Ok(None));
302 /// assert_eq!(Language::for_rule(None, true), Err(MissingLanguage));
303 /// ```
304 pub const fn for_rule(
305 language: Option<Self>,
306 enabled: bool,
307 ) -> Result<Option<Self>, MissingLanguage> {
308 match (enabled, language) {
309 (false, _) => Ok(None),
310 (true, Some(_)) => Ok(language),
311 (true, None) => Err(MissingLanguage),
312 }
313 }
314}
315
316impl FromStr for Language {
317 type Err = UnknownLanguage;
318
319 /// Parses an ISO 639 code, a BCP 47 tag or a POSIX locale, in any case.
320 ///
321 /// The input is trimmed and cut at its first `-`, `_`, `.` or `@`, and
322 /// that primary subtag is looked up among every language's
323 /// [`codes`](Self::codes): `"pt-BR"`, `"ca-ES-valencia"`, `"ca@valencia"`
324 /// and `"pt_BR.UTF-8"` all resolve. BCP 47 tags and POSIX locales are ASCII,
325 /// so non-ASCII input is an error anywhere after trimming (`"en-é"`).
326 ///
327 /// # Examples
328 /// ```
329 /// use csan_core::Language;
330 ///
331 /// assert_eq!("EN".parse(), Ok(Language::English));
332 /// assert_eq!("dut".parse(), Ok(Language::Dutch));
333 /// assert_eq!("ca-valencia".parse(), Ok(Language::Catalan));
334 /// assert!("val".parse::<Language>().is_err()); // ISO 639-3: Vehes
335 /// ```
336 fn from_str(s: &str) -> Result<Self, Self::Err> {
337 let trimmed = s.trim();
338 let unknown = || UnknownLanguage(trimmed.to_owned());
339 // BCP 47 tags and POSIX locales are ASCII, so any non-ASCII input is an
340 // error, even after the primary subtag ("en-é").
341 if !trimmed.is_ascii() {
342 return Err(unknown());
343 }
344 // `split` always yields at least one (possibly empty) piece.
345 let primary = trimmed
346 .split(['-', '_', '.', '@'])
347 .next()
348 .unwrap_or_default();
349 Self::ALL
350 .iter()
351 .copied()
352 .find(|language| {
353 language
354 .codes()
355 .iter()
356 .any(|code| code.eq_ignore_ascii_case(primary))
357 })
358 .ok_or_else(unknown)
359 }
360}
361
362impl Display for Language {
363 /// Writes the canonical code (e.g. `en`), honouring width and fill.
364 fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
365 f.pad(self.code())
366 }
367}
368
369/// The error for a code that no supported language uses.
370///
371/// It holds the trimmed input, in its original case. Its message lists the
372/// canonical codes from [`Language::ALL`]:
373/// `unknown language "xx" (expected one of: af, br, ca, …)`.
374#[derive(Debug, Clone, PartialEq, Eq, Hash, derive_more::Error)]
375pub struct UnknownLanguage(#[error(ignore)] String);
376
377impl Display for UnknownLanguage {
378 fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
379 // Escaped char by char, so that combining marks and other grapheme
380 // extenders are escaped wherever they are (`str::escape_debug` leaves
381 // them unescaped after the first char), without `{:?}` (`use_debug`).
382 // An apostrophe is written as is, as `{:?}` on a string does (`"l'"`).
383 f.write_str("unknown language \"")?;
384 for c in self.0.chars() {
385 if c == '\'' {
386 f.write_char(c)?;
387 } else {
388 for escaped in c.escape_debug() {
389 f.write_char(escaped)?;
390 }
391 }
392 }
393 f.write_str("\" (expected one of: ")?;
394 for (i, language) in Language::ALL.iter().enumerate() {
395 if i > 0 {
396 f.write_str(", ")?;
397 }
398 f.write_str(language.code())?;
399 }
400 f.write_str(")")
401 }
402}
403
404/// The error for a rule switched on without a language, from
405/// [`Language::for_rule`].
406///
407/// Its message is generic (`a language is required`): a frontend reports it
408/// in its own words, naming its own parameters (the CLI says `--strip-article
409/// requires --lang`).
410#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, derive_more::Display, derive_more::Error)]
411#[display("a language is required")]
412pub struct MissingLanguage;
413
414#[cfg(test)]
415mod tests {
416 use super::*;
417 use std::collections::HashSet;
418 use std::error::Error;
419
420 use Language as L;
421
422 #[test]
423 fn article_lists_are_pinned() {
424 // Each list is part of the stability contract (a change in what is
425 // stripped changes callers' numbers), so a change to one must also
426 // change this test.
427 let pinned: &[(Language, &str)] = &[
428 (L::Afrikaans, "die 'n"),
429 (L::Albanian, ""),
430 (L::Breton, "an al ar un ul ur eun eul eur"),
431 (L::Catalan, "el els la les l' un una"),
432 (L::Danish, "de den det en et"),
433 (L::Dutch, "de het een eene 't 'n"),
434 (L::English, "a an the"),
435 (L::Esperanto, "la"),
436 (L::French, "le la les l' un une"),
437 (L::Galician, "o a os as un unha"),
438 (L::German, "der die das ein eine"),
439 (L::Hungarian, "a az egy"),
440 (L::Icelandic, "hinn hin hið hinir hinar"),
441 (L::Irish, "an na"),
442 (L::Italian, "il lo la l' i gli gl' le un uno una un'"),
443 (L::Latin, ""),
444 (L::Luxembourgish, "den de d' deen dat déi en e eng"),
445 (L::Maltese, "il- l- iċ- id- in- ir- is- it- ix- iż- iz- ic-"),
446 (L::Norwegian, "de dei den det e ei ein eit en et"),
447 (
448 L::Occitan,
449 "il l' la las le les lh lhi li lis lo los lou lu lei leis un una uno uns us",
450 ),
451 (L::Portuguese, "o a os as um uma"),
452 (L::Romanian, "un o"),
453 (L::Spanish, "el la los las lo un una"),
454 (L::Swedish, "de den det en ett"),
455 (L::Welsh, "y yr"),
456 ];
457 assert_eq!(pinned.len(), L::ALL.len());
458 for &(language, list) in pinned {
459 let want: Vec<&str> = list.split_whitespace().collect();
460 assert_eq!(language.articles(), want.as_slice(), "{language}");
461 }
462 }
463
464 #[test]
465 fn names_and_codes_are_pinned() {
466 // Removing a code breaks callers that pass it, so the accepted
467 // codes are pinned like the articles, and so are the names that make
468 // Valencian, Flemish, Moldavian and Provençal findable in help text.
469 let pinned: &[(Language, &str, &str)] = &[
470 (L::Afrikaans, "Afrikaans", "af afr"),
471 (L::Albanian, "Albanian", "sq sqi alb als aln aae aat"),
472 (L::Breton, "Breton", "br bre"),
473 (L::Catalan, "Catalan (Valencian)", "ca cat"),
474 (L::Danish, "Danish", "da dan"),
475 (L::Dutch, "Dutch (Flemish)", "nl nld dut"),
476 (L::English, "English", "en eng"),
477 (L::Esperanto, "Esperanto", "eo epo"),
478 (L::French, "French", "fr fra fre"),
479 (L::Galician, "Galician", "gl glg"),
480 (L::German, "German", "de deu ger"),
481 (L::Hungarian, "Hungarian", "hu hun"),
482 (L::Icelandic, "Icelandic", "is isl ice"),
483 (L::Irish, "Irish", "ga gle"),
484 (L::Italian, "Italian", "it ita"),
485 (L::Latin, "Latin", "la lat"),
486 (L::Luxembourgish, "Luxembourgish", "lb ltz"),
487 (L::Maltese, "Maltese", "mt mlt"),
488 (L::Norwegian, "Norwegian", "no nor nb nob nn nno"),
489 (L::Occitan, "Occitan (Provençal)", "oc oci pro"),
490 (L::Portuguese, "Portuguese", "pt por"),
491 (L::Romanian, "Romanian (Moldavian)", "ro ron rum mo mol"),
492 (L::Spanish, "Spanish", "es spa"),
493 (L::Swedish, "Swedish", "sv swe"),
494 (L::Welsh, "Welsh", "cy cym wel"),
495 ];
496 assert_eq!(pinned.len(), L::ALL.len());
497 for &(language, name, list) in pinned {
498 assert_eq!(language.name(), name, "{language}");
499 let want: Vec<&str> = list.split_whitespace().collect();
500 assert_eq!(language.codes(), want.as_slice(), "{language}");
501 }
502 }
503
504 #[test]
505 fn excluded_forms_are_not_listed() {
506 // The forms left out on purpose, for the reasons the module doc
507 // gives; none may reappear unnoticed.
508 let excluded: &[(Language, &[&str])] = &[
509 (L::English, &["ye", "d'", "de"]),
510 (L::Catalan, &["en", "uns", "unes"]),
511 (L::Romanian, &["unui", "unei", "a", "al", "ai", "ale"]),
512 (
513 L::German,
514 &[
515 "des", "dem", "den", "einer", "eines", "einem", "einen", "'s",
516 ],
517 ),
518 (
519 L::Icelandic,
520 &[
521 "hina", "hinum", "hinni", "hinu", "hins", "hinnar", "hinna", "'r",
522 ],
523 ),
524 (L::Occitan, &["ih'"]),
525 (L::Spanish, &["unos", "unas"]),
526 (L::Portuguese, &["uns", "umas"]),
527 (L::Galician, &["uns", "unhas"]),
528 (L::Albanian, &["një", "nje", "nji", "njē"]),
529 (L::Luxembourgish, &["dem", "der", "engem", "enger"]),
530 ];
531 for &(language, forms) in excluded {
532 for form in forms {
533 assert!(
534 !language.articles().contains(form),
535 "{language} lists {form}"
536 );
537 }
538 }
539 }
540
541 #[test]
542 fn table_invariants() {
543 let unique: HashSet<_> = L::ALL.iter().collect();
544 assert_eq!(unique.len(), L::ALL.len(), "ALL lists a language twice");
545 assert!(
546 L::ALL.is_sorted_by_key(|language| language.code()),
547 "ALL must be ordered by code"
548 );
549 let mut seen = HashSet::new();
550 for &language in L::ALL {
551 assert_eq!(language.codes().first(), Some(&language.code()));
552 assert_eq!(language.code().len(), 2, "{language}: not ISO 639-1");
553 assert_eq!(language.to_string(), language.code());
554 assert_eq!(format!("{language:>4}"), format!("{:>4}", language.code()));
555 for &code in language.codes() {
556 assert!(seen.insert(code), "{code} belongs to two languages");
557 assert!(
558 code.bytes().all(|b| b.is_ascii_lowercase()),
559 "{language}: {code:?} is not lowercase ASCII"
560 );
561 }
562 for &article in language.articles() {
563 assert_eq!(article, article.to_lowercase(), "{language} {article}");
564 assert!(!article.contains(char::is_whitespace));
565 // Precomposed: no combining diacritical marks (U+0300–U+036F),
566 // which an NFD spelling would carry.
567 assert!(
568 !article
569 .chars()
570 .any(|c| ('\u{300}'..='\u{36F}').contains(&c)),
571 "{language} {article} is not precomposed"
572 );
573 }
574 let mut articles = HashSet::new();
575 for &article in language.articles() {
576 assert!(articles.insert(article), "{language} lists {article} twice");
577 }
578 }
579 }
580
581 #[test]
582 fn every_code_resolves_in_any_case() {
583 for &language in L::ALL {
584 for &code in language.codes() {
585 assert_eq!(code.parse(), Ok(language), "{code}");
586 assert_eq!(code.to_uppercase().parse(), Ok(language), "{code}");
587 let mut mixed = code.to_owned();
588 mixed.get_mut(..1).map(str::make_ascii_uppercase);
589 assert_eq!(mixed.parse(), Ok(language), "{mixed}");
590 assert_eq!(format!(" {code}\t").parse(), Ok(language), "{code}");
591 }
592 }
593 }
594
595 #[test]
596 fn tags_and_locales_resolve() {
597 let cases = [
598 ("pt-BR", L::Portuguese),
599 ("ca-ES-valencia", L::Catalan),
600 ("ca-valencia", L::Catalan),
601 ("ca@valencia", L::Catalan),
602 ("nb_NO", L::Norwegian),
603 ("nn-NO", L::Norwegian),
604 ("pt_BR.UTF-8", L::Portuguese),
605 ("de.UTF-8", L::German),
606 ("sv-s", L::Swedish),
607 ("sv-SE", L::Swedish),
608 ("mo", L::Romanian),
609 // Unicode whitespace around the code is trimmed before the ASCII
610 // check.
611 ("\u{A0}en\u{3000}", L::English),
612 ];
613 for (code, want) in cases {
614 assert_eq!(code.parse(), Ok(want), "{code}");
615 }
616 }
617
618 #[test]
619 fn bad_codes_are_errors() {
620 // `val` (Vehes), `iri` (Rigwe) and `gag` (Gagauz) are other
621 // languages in ISO 639-3; the rest aren't codes at all.
622 for code in [
623 "val",
624 "iri",
625 "gag",
626 "xx",
627 "",
628 " ",
629 "-",
630 "_en",
631 "énglish",
632 "en\u{301}",
633 "e n",
634 "en-é",
635 "ca@valència",
636 ] {
637 let err = code.parse::<Language>().unwrap_err();
638 assert_eq!(err, UnknownLanguage(code.trim().to_owned()));
639 }
640 // "eng-" is the code `eng` followed by an empty subtag, not an error.
641 assert_eq!("eng-".parse(), Ok(L::English));
642 }
643
644 #[test]
645 fn error_message_lists_the_codes() {
646 let message = "XX".parse::<Language>().unwrap_err().to_string();
647 let codes: Vec<&str> = L::ALL.iter().map(|language| language.code()).collect();
648 assert_eq!(
649 message,
650 format!(
651 "unknown language \"XX\" (expected one of: {})",
652 codes.join(", ")
653 )
654 );
655 // Control characters and quotes in the input are escaped.
656 let escaped = UnknownLanguage("a\"b\n".to_owned()).to_string();
657 assert!(
658 escaped.starts_with(r#"unknown language "a\"b\n" ("#),
659 "{escaped}"
660 );
661 // An apostrophe is written as is, as `{:?}` would.
662 let apostrophe = UnknownLanguage("l'".to_owned()).to_string();
663 assert!(
664 apostrophe.starts_with(r#"unknown language "l'" ("#),
665 "{apostrophe}"
666 );
667 // A combining mark is escaped wherever it is, not only first.
668 let combining = UnknownLanguage("en\u{301}x\u{301}".to_owned()).to_string();
669 assert!(
670 combining.starts_with(r#"unknown language "en\u{301}x\u{301}" ("#),
671 "{combining}"
672 );
673 }
674
675 #[test]
676 fn from_optional_treats_blank_as_absent() {
677 assert_eq!(L::from_optional(None), Ok(None));
678 assert_eq!(L::from_optional(Some("")), Ok(None));
679 assert_eq!(L::from_optional(Some(" \t ")), Ok(None));
680 assert_eq!(L::from_optional(Some(" EN ")), Ok(Some(L::English)));
681 assert_eq!(
682 L::from_optional(Some("xx")),
683 Err(UnknownLanguage("xx".to_owned()))
684 );
685 }
686
687 #[test]
688 fn only_languages_without_articles_have_none() {
689 // Every language carries reviewed article data. An empty list is
690 // allowed only for the languages G 100 §9 lists as having no initial
691 // articles, so that a language added for another rule can't silently
692 // strip nothing.
693 let without: Vec<Language> = L::ALL
694 .iter()
695 .copied()
696 .filter(|language| language.articles().is_empty())
697 .collect();
698 assert_eq!(without, [L::Latin, L::Albanian]);
699 }
700
701 #[test]
702 fn for_rule_needs_a_language_only_when_on() {
703 for language in [None, Some(L::English)] {
704 assert_eq!(L::for_rule(language, false), Ok(None));
705 }
706 for &language in L::ALL {
707 assert_eq!(L::for_rule(Some(language), true), Ok(Some(language)));
708 }
709 assert_eq!(L::for_rule(None, true), Err(MissingLanguage));
710 assert_eq!(MissingLanguage.to_string(), "a language is required");
711 }
712
713 #[test]
714 fn errors_are_send_sync_and_std_error() {
715 fn assert_traits<T: Error + Send + Sync + 'static>() {}
716 assert_traits::<UnknownLanguage>();
717 assert_traits::<MissingLanguage>();
718 }
719}