Skip to main content

didbot_schema/format/
language.rs

1//! The `language` string format: an IETF BCP 47 language tag.
2//!
3//! BCP 47 in full is a registry lookup, and this is deliberately not that. It
4//! checks the grammar — a primary subtag, then subtags of one to eight
5//! alphanumerics — and one semantic rule that a grammar check misses:
6//! RFC 5646 §2.2.9 forbids a variant or extension singleton subtag from
7//! appearing twice, so `de-DE-1901-1901` and `en-a-foo-a-bar` are well formed
8//! and not valid tags. Upstream keeps those in `language_parse_invalid.txt`
9//! for the same reason it separates datetime's calendar cases.
10//!
11//! The primary subtag is required to be two or three lowercase letters, which
12//! is narrower than the RFC's ABNF: that also admits a four-letter reserved
13//! range and five-to-eight-letter registered languages. The interop vectors
14//! declare `jaja` invalid and `JA` invalid, and following the vectors here
15//! costs nothing — no language in current use has a primary subtag outside
16//! ISO 639-1 and 639-3.
17//!
18//! The two irregular openings are kept: `x-` starts a wholly private-use tag
19//! and `i-` starts a grandfathered one, and both bypass the primary-subtag
20//! rule because neither has one.
21
22use std::collections::BTreeSet;
23
24/// Longest any one subtag may be.
25pub const MAX_SUBTAG_LENGTH: usize = 8;
26
27/// Why a string is not a valid language tag.
28#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
29pub enum LanguageError {
30    /// Empty, or a leading, trailing or doubled hyphen.
31    #[error("language tag has an empty subtag")]
32    EmptySubtag,
33    /// A subtag over [`MAX_SUBTAG_LENGTH`] characters.
34    #[error("language subtag {0:?} is over {MAX_SUBTAG_LENGTH} characters")]
35    SubtagTooLong(String),
36    /// A character outside `A-Za-z0-9-`.
37    #[error("invalid character {0:?} in language tag")]
38    InvalidCharacter(char),
39    /// The primary subtag is not two or three lowercase letters, and the tag
40    /// does not open with the private-use or grandfathered singleton.
41    #[error("language primary subtag {0:?} is not two or three lowercase letters")]
42    BadPrimarySubtag(String),
43    /// `x` or `i` with nothing after it.
44    #[error("language tag {0:?} is a bare singleton with no subtags")]
45    BareSingleton(String),
46    /// A variant or extension singleton subtag appears twice.
47    #[error("language subtag {0:?} is repeated")]
48    RepeatedSubtag(String),
49}
50
51/// Checks a string against the language tag rules.
52///
53/// ```
54/// use didbot_schema::format::language;
55/// assert!(language::validate("en").is_ok());
56/// assert!(language::validate("pt-BR").is_ok());
57/// assert!(language::validate("hy-Latn-IT-arevela").is_ok());
58/// assert!(language::validate("i-navajo").is_ok());
59/// assert!(language::validate("JA").is_err());            // primary subtag is lowercase
60/// assert!(language::validate("de-DE-1901-1901").is_err()); // repeated variant
61/// ```
62pub fn validate(value: &str) -> Result<(), LanguageError> {
63    if let Some(ch) = value
64        .chars()
65        .find(|ch| !(ch.is_ascii_alphanumeric() || *ch == '-'))
66    {
67        return Err(LanguageError::InvalidCharacter(ch));
68    }
69    let subtags: Vec<&str> = value.split('-').collect();
70    for subtag in &subtags {
71        if subtag.is_empty() {
72            return Err(LanguageError::EmptySubtag);
73        }
74        if subtag.len() > MAX_SUBTAG_LENGTH {
75            return Err(LanguageError::SubtagTooLong((*subtag).to_owned()));
76        }
77    }
78
79    let primary = subtags[0];
80    // `x-` is a wholly private-use tag and `i-` a grandfathered one. Neither
81    // carries a primary language subtag, and neither can carry a duplicate
82    // that means anything, so both are accepted whole.
83    if primary.eq_ignore_ascii_case("x") || primary.eq_ignore_ascii_case("i") {
84        if subtags.len() < 2 {
85            return Err(LanguageError::BareSingleton(primary.to_owned()));
86        }
87        return Ok(());
88    }
89    if !(matches!(primary.len(), 2 | 3) && primary.bytes().all(|b| b.is_ascii_lowercase())) {
90        return Err(LanguageError::BadPrimarySubtag(primary.to_owned()));
91    }
92
93    // RFC 5646 §2.2.9: no variant subtag and no extension singleton may
94    // repeat. Both comparisons are case-insensitive, because subtag case is
95    // presentational only and `en-rozaj-ROZAJ` is the same tag twice.
96    let mut seen: BTreeSet<String> = BTreeSet::new();
97    for subtag in &subtags[1..] {
98        if subtag.eq_ignore_ascii_case("x") {
99            // Everything after the private-use singleton is opaque.
100            break;
101        }
102        let is_singleton = subtag.len() == 1;
103        // A variant is five to eight alphanumerics, or four beginning with a
104        // digit. Two- and three-letter subtags are scripts, regions and
105        // extended languages, which may legitimately look alike.
106        let is_variant = subtag.len() >= 5
107            || (subtag.len() == 4 && subtag.starts_with(|c: char| c.is_ascii_digit()));
108        if (is_singleton || is_variant) && !seen.insert(subtag.to_ascii_lowercase()) {
109            return Err(LanguageError::RepeatedSubtag((*subtag).to_owned()));
110        }
111    }
112    Ok(())
113}
114
115#[cfg(test)]
116mod tests {
117    use super::*;
118
119    #[test]
120    fn scripts_and_regions_may_look_alike_without_being_duplicates() {
121        // Only variants and extension singletons are constrained; a script and
122        // a region of the same spelling are different slots.
123        assert!(validate("sgn-BE-NL").is_ok());
124        assert!(validate("zh-Hant-HK").is_ok());
125    }
126
127    #[test]
128    fn a_repeated_extension_singleton_is_refused_case_insensitively() {
129        assert_eq!(
130            validate("en-u-co-phonebk-U-ca-buddhist"),
131            Err(LanguageError::RepeatedSubtag("U".to_owned()))
132        );
133    }
134
135    #[test]
136    fn private_use_subtags_are_opaque() {
137        // Everything after `x` is by definition meaningless to us, so a
138        // repeat inside it is not an error.
139        assert!(validate("en-x-foo-foo").is_ok());
140        assert!(validate("X-fr-CH").is_ok());
141    }
142}