accept_language/
lib.rs

1//! `accept-language` is a tiny library for parsing the Accept-Language header from browsers (as defined [here](https://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html)).
2//!
3//! It's intended to be used in a web server that supports some level of internationalization (i18n),
4//! but can be used anytime an Accept-Language header string is available.
5//!
6//! In order to help facilitate better i18n, a function is provided to return the intersection of
7//! the languages the user prefers and the languages your application supports.
8//! You can try the `cargo test` and `cargo bench` to verify the behaviour.
9//!
10//! # Example
11//!
12//! ```
13//! use accept_language::{intersection, parse};
14//!
15//! let user_languages = parse("en-US, en-GB;q=0.5");
16//! let common_languages = intersection("en-US, en-GB;q=0.5", &["en-US", "de", "en-GB"]);
17//! ```
18use std::cmp::Ordering;
19use std::str;
20use std::str::FromStr;
21
22#[derive(Debug)]
23struct Language {
24    name: String,
25    quality: f32,
26}
27
28impl Eq for Language {}
29
30impl Ord for Language {
31    fn cmp(&self, other: &Language) -> Ordering {
32        if self.quality > other.quality {
33            Ordering::Less
34        } else if self.quality < other.quality {
35            Ordering::Greater
36        } else {
37            Ordering::Equal
38        }
39    }
40}
41
42impl PartialOrd for Language {
43    fn partial_cmp(&self, other: &Language) -> Option<Ordering> {
44        Some(self.cmp(other))
45    }
46}
47
48impl PartialEq for Language {
49    fn eq(&self, other: &Language) -> bool {
50        self.quality == other.quality && self.name.to_lowercase() == other.name.to_lowercase()
51    }
52}
53
54impl Language {
55    fn new(tag: &str) -> Language {
56        let tag_parts: Vec<&str> = tag.split(';').collect();
57        let name = tag_parts[0].to_string();
58        let quality = match tag_parts.len() {
59            1 => 1.0,
60            _ => Language::quality_with_default(tag_parts[1]),
61        };
62        Language { name, quality }
63    }
64
65    fn quality_with_default(raw_quality: &str) -> f32 {
66        let quality_parts: Vec<&str> = raw_quality.split('=').collect();
67        match quality_parts.len() {
68            2 => f32::from_str(quality_parts[1]).unwrap_or(0.0),
69            _ => 0.0,
70        }
71    }
72}
73
74/// Parse a raw Accept-Language header value into an ordered list of language tags.
75/// This should return the exact same list as `window.navigator.languages` in supported browsers.
76///
77/// # Example
78///
79/// ```
80/// use accept_language::parse;
81///
82/// let user_languages = parse("en-US, en-GB;q=0.5");
83/// ```
84pub fn parse(raw_languages: &str) -> Vec<String> {
85    let stripped_languages = raw_languages.to_owned().replace(' ', "");
86    let language_strings: Vec<&str> = stripped_languages.split(',').collect();
87    let mut languages: Vec<Language> = language_strings.iter().map(|l| Language::new(l)).collect();
88    languages.sort();
89    languages
90        .iter()
91        .map(|l| l.name.to_owned())
92        .filter(|l| !l.is_empty())
93        .collect()
94}
95
96/// Similar to [`parse`](parse) but with quality `f32` appended to notice if it is a default value.
97/// is used by [`intersection_with_quality`](intersection_with_quality) and
98/// [`intersection_ordered_with_quality`](intersection_ordered_with_quality).
99///
100/// # Example
101///
102/// ```
103/// use accept_language::parse_with_quality;
104///
105/// let user_languages = parse_with_quality("en-US, en-GB;q=0.5");
106/// assert_eq!(user_languages,vec![(String::from("en-US"), 1.0), (String::from("en-GB"), 0.5)])
107/// ```
108pub fn parse_with_quality(raw_languages: &str) -> Vec<(String, f32)> {
109    let stripped_languages = raw_languages.to_owned().replace(' ', "");
110    let language_strings: Vec<&str> = stripped_languages.split(',').collect();
111    let mut languages: Vec<Language> = language_strings.iter().map(|l| Language::new(l)).collect();
112    languages.sort();
113    languages
114        .iter()
115        .map(|l| (l.name.to_owned(), l.quality))
116        .filter(|l| !l.0.is_empty())
117        .collect()
118}
119
120/// Compare an Accept-Language header value with your application's supported languages to find
121/// the common languages that could be presented to a user.
122///
123/// # Example
124///
125/// ```
126/// use accept_language::intersection;
127///
128/// let common_languages = intersection("en-US, en-GB;q=0.5", &["en-US", "de", "en-GB"]);
129/// ```
130pub fn intersection(raw_languages: &str, supported_languages: &[&str]) -> Vec<String> {
131    let user_languages = parse(raw_languages);
132    user_languages
133        .into_iter()
134        .filter(|l| supported_languages.contains(&l.as_str()))
135        .collect()
136}
137/// Similar to [`intersection`](intersection) but using binary sort. The supported languages
138/// MUST be in alphabetical order, to find the common languages that could be presented
139/// to a user. Executes roughly 25% faster.
140///
141/// # Example
142///
143/// ```
144/// use accept_language::intersection_ordered;
145///
146/// let common_languages = intersection_ordered("en-US, en-GB;q=0.5", &["de", "en-GB", "en-US"]);
147/// ```
148pub fn intersection_ordered(raw_languages: &str, supported_languages: &[&str]) -> Vec<String> {
149    let user_languages = parse(raw_languages);
150    user_languages
151        .into_iter()
152        .filter(|l| supported_languages.binary_search(&l.as_str()).is_ok())
153        .collect()
154}
155/// Similar to [`intersection`](intersection) but with the quality as `f32` appended for each language.
156/// This enables distinction between the default language of a user (value 1.0) and the
157/// best match. If you don't want to assign your users immediatly to a non-default choice and you plan to add
158/// more languages later on in your webserver.
159///
160/// # Example
161///
162/// ```
163/// use accept_language::intersection_with_quality;
164///
165/// let common_languages = intersection_with_quality("en-US, en-GB;q=0.5", &["en-US", "de", "en-GB"]);
166/// assert_eq!(common_languages,vec![(String::from("en-US"), 1.0), (String::from("en-GB"), 0.5)])
167/// ```
168pub fn intersection_with_quality(
169    raw_languages: &str,
170    supported_languages: &[&str],
171) -> Vec<(String, f32)> {
172    let user_languages = parse_with_quality(raw_languages);
173    user_languages
174        .into_iter()
175        .filter(|l| supported_languages.contains(&l.0.as_str()))
176        .collect()
177}
178
179/// Similar to [`intersection_with_quality`](intersection_with_quality). The supported languages MUST
180/// be in alphabetical order, to find the common languages that could be presented to a user.
181/// Executes roughly 25% faster.
182///
183/// # Example
184///
185/// ```
186/// use accept_language::intersection_ordered_with_quality;
187///
188/// let common_languages = intersection_ordered_with_quality("en-US, en-GB;q=0.5", &["de", "en-GB", "en-US"]);
189/// assert_eq!(common_languages,vec![(String::from("en-US"), 1.0), (String::from("en-GB"), 0.5)])
190/// ```
191pub fn intersection_ordered_with_quality(
192    raw_languages: &str,
193    supported_languages: &[&str],
194) -> Vec<(String, f32)> {
195    let user_languages = parse_with_quality(raw_languages);
196    user_languages
197        .into_iter()
198        .filter(|l| supported_languages.binary_search(&l.0.as_str()).is_ok())
199        .collect()
200}
201
202#[cfg(test)]
203mod tests {
204    use super::{
205        intersection, intersection_ordered, intersection_ordered_with_quality,
206        intersection_with_quality, parse, Language,
207    };
208
209    static MOCK_ACCEPT_LANGUAGE: &str = "en-US, de;q=0.7, zh-Hant, jp;q=0.1";
210    static AVIALABLE_LANGUAGES: &[&str] =
211        &["da", "de", "en-US", "it", "jp", "zh", "zh-Hans", "zh-Hant"];
212
213    #[test]
214    fn it_creates_a_new_language_from_a_string() {
215        let language = Language::new("en-US;q=0.7");
216        assert_eq!(
217            language,
218            Language {
219                name: String::from("en-US"),
220                quality: 0.7,
221            }
222        )
223    }
224
225    #[test]
226    fn it_creates_a_new_language_from_a_string_with_lowercase_country() {
227        let language = Language::new("en-us;q=0.7");
228        assert_eq!(
229            language,
230            Language {
231                name: String::from("en-US"),
232                quality: 0.7,
233            }
234        )
235    }
236
237    #[test]
238    fn it_creates_a_new_language_from_a_string_with_a_default_quality() {
239        let language = Language::new("en-US");
240        assert_eq!(
241            language,
242            Language {
243                name: String::from("en-US"),
244                quality: 1.0,
245            }
246        )
247    }
248
249    #[test]
250    fn it_parses_quality() {
251        let quality = Language::quality_with_default("q=0.5");
252        assert_eq!(quality, 0.5)
253    }
254
255    #[test]
256    fn it_parses_an_invalid_quality() {
257        let quality = Language::quality_with_default("q=yolo");
258        assert_eq!(quality, 0.0)
259    }
260
261    #[test]
262    fn it_parses_a_valid_accept_language_header() {
263        let user_languages = parse(MOCK_ACCEPT_LANGUAGE);
264        assert_eq!(
265            user_languages,
266            vec![
267                String::from("en-US"),
268                String::from("zh-Hant"),
269                String::from("de"),
270                String::from("jp"),
271            ]
272        )
273    }
274
275    #[test]
276    fn it_parses_an_empty_accept_language_header() {
277        let user_languages = parse("");
278        assert_eq!(user_languages.len(), 0)
279    }
280
281    #[test]
282    fn it_parses_an_invalid_accept_language_header() {
283        let user_languages_one = parse("q");
284        let user_languages_two = parse(";q");
285        let user_languages_three = parse("q-");
286        let user_languages_four = parse("en;q=");
287        assert_eq!(user_languages_one, vec![String::from("q")]);
288        assert_eq!(user_languages_two.len(), 0);
289        assert_eq!(user_languages_three, vec![String::from("q-")]);
290        assert_eq!(user_languages_four, vec![String::from("en")])
291    }
292
293    #[test]
294    fn it_sorts_languages_by_quality() {
295        let user_languages = parse("en-US, de;q=0.1, jp;q=0.7");
296        assert_eq!(
297            user_languages,
298            vec![
299                String::from("en-US"),
300                String::from("jp"),
301                String::from("de"),
302            ]
303        )
304    }
305
306    #[test]
307    fn it_returns_language_intersection() {
308        let common_languages = intersection(MOCK_ACCEPT_LANGUAGE, AVIALABLE_LANGUAGES);
309        assert_eq!(
310            common_languages,
311            vec![
312                String::from("en-US"),
313                String::from("zh-Hant"),
314                String::from("de"),
315                String::from("jp")
316            ]
317        )
318    }
319
320    #[test]
321    fn it_returns_language_intersection_ordered() {
322        let common_languages = intersection_ordered(MOCK_ACCEPT_LANGUAGE, AVIALABLE_LANGUAGES);
323        assert_eq!(
324            common_languages,
325            vec![
326                String::from("en-US"),
327                String::from("zh-Hant"),
328                String::from("de"),
329                String::from("jp")
330            ]
331        )
332    }
333
334    #[test]
335    fn it_returns_language_intersection_with_quality() {
336        let common_languages = intersection_with_quality(MOCK_ACCEPT_LANGUAGE, &["en-US", "jp"]);
337        assert_eq!(
338            common_languages,
339            vec![(String::from("en-US"), 1.0), (String::from("jp"), 0.1)]
340        )
341    }
342
343    #[test]
344    fn it_returns_language_intersection_ordered_with_quality() {
345        let common_languages =
346            intersection_ordered_with_quality(MOCK_ACCEPT_LANGUAGE, &["en-US", "jp"]);
347        assert_eq!(
348            common_languages,
349            vec![(String::from("en-US"), 1.0), (String::from("jp"), 0.1)]
350        )
351    }
352
353    #[test]
354    fn it_returns_an_empty_array_when_no_intersection() {
355        let common_languages = intersection(MOCK_ACCEPT_LANGUAGE, &["fr", "en-GB"]);
356        assert_eq!(common_languages.len(), 0)
357    }
358
359    #[test]
360    fn it_parses_traditional_chinese() {
361        assert_eq!(parse("zh-Hant"), &["zh-Hant"]);
362    }
363
364    #[test]
365    fn it_implements_case_insensitive_equality() {
366        assert_eq!(Language::new("en-US"), Language::new("en-us"));
367        assert_eq!(Language::new("en-US;q=0.7"), Language::new("en-us;q=0.7"));
368        assert_ne!(Language::new("en"), Language::new("en-US"));
369        assert_ne!(Language::new("en;q=0.7"), Language::new("en;q=0.8"));
370        assert_ne!(Language::new("en;q=0.7"), Language::new("en-US;q=0.7"));
371    }
372}