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}