Skip to main content

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}