fluent_locale/negotiate/
mod.rs

1//! Language Negotiation is a process in which locales from different
2//! sources are filtered and sorted in an effort to produce the best
3//! possible selection of them.
4//!
5//! There are multiple language negotiation strategies, most popular is
6//! described in [RFC4647](https://www.ietf.org/rfc/rfc4647.txt).
7//!
8//! The algorithm is based on the BCP4647 3.3.2 Extended Filtering algorithm,
9//! with several modifications.
10//!
11//! # Example:
12//!
13//! ```
14//! use fluent_locale::negotiate_languages;
15//! use fluent_locale::NegotiationStrategy;
16//! use fluent_locale::convert_vec_str_to_langids_lossy;
17//! use unic_langid::LanguageIdentifier;
18//!
19//! let requested = convert_vec_str_to_langids_lossy(&["pl", "fr", "en-US"]);
20//! let available = convert_vec_str_to_langids_lossy(&["it", "de", "fr", "en-GB", "en_US"]);
21//! let default: LanguageIdentifier = "en-US".parse().expect("Parsing langid failed.");
22//!
23//! let supported = negotiate_languages(
24//!   &requested,
25//!   &available,
26//!   Some(&default),
27//!   NegotiationStrategy::Filtering
28//! );
29//!
30//! let expected = convert_vec_str_to_langids_lossy(&["fr", "en-US", "en-GB"]);
31//! assert_eq!(supported,
32//!            expected.iter().map(|t| t.as_ref()).collect::<Vec<&LanguageIdentifier>>());
33//! ```
34//!
35//! # The exact algorithm is custom, and consists of a 6 level strategy:
36//!
37//! ### 1) Attempt to find an exact match for each requested locale in available locales.
38//!
39//! Example:
40//!
41//! ```text
42//! // [requested] * [available] = [supported]
43//!
44//! ["en-US"] * ["en-US"] = ["en-US"]
45//! ```
46//!
47//! ### 2) Attempt to match a requested locale to an available locale treated as a locale range.
48//!
49//! Example:
50//!
51//! ```text
52//! // [requested] * [available] = [supported]
53//!
54//! ["en-US"] * ["en"] = ["en"]
55//!               ^^
56//!                |-- becomes "en-*-*-*"
57//! ```
58//!
59//! ### 3) Maximize the requested locale to find the best match in available locales.
60//!
61//! This part uses ICU's likelySubtags or similar database.
62//!
63//! Example:
64//!
65//! ```text
66//! // [requested] * [available] = [supported]
67//!
68//! ["en"] * ["en-GB", "en-US"] = ["en-US"]
69//!   ^^       ^^^^^    ^^^^^
70//!    |           |        |
71//!    |           |----------- become "en-*-GB-*" and "en-*-US-*"
72//!    |
73//!    |-- ICU likelySubtags expands it to "en-Latn-US"
74//! ```
75//!
76//! ### 4) Attempt to look up for a different variant of the same locale.
77//!
78//! Example:
79//!
80//! ```text
81//! // [requested] * [available] = [supported]
82//!
83//! ["ja-JP-win"] * ["ja-JP-mac"] = ["ja-JP-mac"]
84//!   ^^^^^^^^^       ^^^^^^^^^
85//!           |               |-- become "ja-*-JP-mac"
86//!           |
87//!           |----------- replace variant with range: "ja-JP-*"
88//! ```
89//!
90//! ### 5) Look up for a maximized version of the requested locale, stripped of the region code.
91//!
92//! Example:
93//!
94//! ```text
95//! // [requested] * [available] = [supported]
96//!
97//! ["en-CA"] * ["en-ZA", "en-US"] = ["en-US", "en-ZA"]
98//!   ^^^^^
99//!       |       ^^^^^    ^^^^^
100//!       |           |        |
101//!       |           |----------- become "en-*-ZA-*" and "en-*-US-*"
102//!       |
103//!       |----------- strip region produces "en", then lookup likelySubtag: "en-Latn-US"
104//! ```
105//!
106//!
107//! ### 6) Attempt to look up for a different region of the same locale.
108//!
109//! Example:
110//!
111//! ```text
112//! // [requested] * [available] = [supported]
113//!
114//! ["en-GB"] * ["en-AU"] = ["en-AU"]
115//!   ^^^^^       ^^^^^
116//!       |           |-- become "en-*-AU-*"
117//!       |
118//!       |----- replace region with range: "en-*"
119//! ```
120//!
121
122use std::collections::HashMap;
123use unic_langid::LanguageIdentifier;
124
125#[cfg(not(feature = "cldr"))]
126mod likely_subtags;
127#[cfg(not(feature = "cldr"))]
128use likely_subtags::MockLikelySubtags;
129
130#[derive(PartialEq, Debug, Clone, Copy)]
131pub enum NegotiationStrategy {
132    Filtering,
133    Matching,
134    Lookup,
135}
136
137pub fn filter_matches<'a, R: 'a + AsRef<LanguageIdentifier>, A: 'a + AsRef<LanguageIdentifier>>(
138    requested: &[R],
139    available: &'a [A],
140    strategy: NegotiationStrategy,
141) -> Vec<&'a A> {
142    let mut supported_locales = vec![];
143
144    let mut av_map: HashMap<&'a LanguageIdentifier, &'a A> = HashMap::new();
145
146    for av in available.iter() {
147        av_map.insert(av.as_ref(), av);
148    }
149
150    for req in requested {
151        let mut req = req.as_ref().to_owned();
152        if req.get_language() == "und" {
153            continue;
154        }
155
156        let mut match_found = false;
157
158        // 1) Try to find a simple (case-insensitive) string match for the request.
159        av_map.retain(|key, value| {
160            if strategy != NegotiationStrategy::Filtering && match_found {
161                return true;
162            }
163
164            if key.matches(&req, false, false) {
165                match_found = true;
166                supported_locales.push(*value);
167                return false;
168            }
169            true
170        });
171
172        if match_found {
173            match strategy {
174                NegotiationStrategy::Filtering => {}
175                NegotiationStrategy::Matching => continue,
176                NegotiationStrategy::Lookup => break,
177            }
178        }
179
180        match_found = false;
181
182        // 2) Try to match against the available locales treated as ranges.
183        av_map.retain(|key, value| {
184            if strategy != NegotiationStrategy::Filtering && match_found {
185                return true;
186            }
187
188            if key.matches(&req, true, false) {
189                match_found = true;
190                supported_locales.push(*value);
191                return false;
192            }
193            true
194        });
195
196        if match_found {
197            match strategy {
198                NegotiationStrategy::Filtering => {}
199                NegotiationStrategy::Matching => continue,
200                NegotiationStrategy::Lookup => break,
201            };
202        }
203
204        match_found = false;
205
206        // 3) Try to match against a maximized version of the requested locale
207        if req.add_likely_subtags() {
208            av_map.retain(|key, value| {
209                if strategy != NegotiationStrategy::Filtering && match_found {
210                    return true;
211                }
212
213                if key.matches(&req, true, false) {
214                    match_found = true;
215                    supported_locales.push(*value);
216                    return false;
217                }
218                true
219            });
220
221            if match_found {
222                match strategy {
223                    NegotiationStrategy::Filtering => {}
224                    NegotiationStrategy::Matching => continue,
225                    NegotiationStrategy::Lookup => break,
226                };
227            }
228
229            match_found = false;
230        };
231
232        // 4) Try to match against a variant as a range
233        req.set_variants(&[]).unwrap();
234        av_map.retain(|key, value| {
235            if strategy != NegotiationStrategy::Filtering && match_found {
236                return true;
237            }
238
239            if key.matches(&req, true, true) {
240                match_found = true;
241                supported_locales.push(*value);
242                return false;
243            }
244            true
245        });
246
247        if match_found {
248            match strategy {
249                NegotiationStrategy::Filtering => {}
250                NegotiationStrategy::Matching => continue,
251                NegotiationStrategy::Lookup => break,
252            };
253        }
254
255        match_found = false;
256
257        // 5) Try to match against the likely subtag without region
258        req.set_region(None).unwrap();
259        if req.add_likely_subtags() {
260            av_map.retain(|key, value| {
261                if strategy != NegotiationStrategy::Filtering && match_found {
262                    return true;
263                }
264
265                if key.matches(&req, true, false) {
266                    match_found = true;
267                    supported_locales.push(*value);
268                    return false;
269                }
270                true
271            });
272
273            if match_found {
274                match strategy {
275                    NegotiationStrategy::Filtering => {}
276                    NegotiationStrategy::Matching => continue,
277                    NegotiationStrategy::Lookup => break,
278                };
279            }
280
281            match_found = false;
282        }
283
284        // 6) Try to match against a region as a range
285        req.set_region(None).unwrap();
286        av_map.retain(|key, value| {
287            if strategy != NegotiationStrategy::Filtering && match_found {
288                return true;
289            }
290
291            if key.matches(&req, true, true) {
292                match_found = true;
293                supported_locales.push(*value);
294                return false;
295            }
296            true
297        });
298
299        if match_found {
300            match strategy {
301                NegotiationStrategy::Filtering => {}
302                NegotiationStrategy::Matching => continue,
303                NegotiationStrategy::Lookup => break,
304            };
305        }
306    }
307
308    supported_locales
309}
310
311pub fn negotiate_languages<
312    'a,
313    R: 'a + AsRef<LanguageIdentifier>,
314    A: 'a + AsRef<LanguageIdentifier>,
315>(
316    requested: &[R],
317    available: &'a [A],
318    default: Option<&'a A>,
319    strategy: NegotiationStrategy,
320) -> Vec<&'a A> {
321    let mut supported = filter_matches(requested, available, strategy);
322
323    if let Some(default) = default {
324        if strategy == NegotiationStrategy::Lookup {
325            if supported.is_empty() {
326                supported.push(default);
327            }
328        } else if !supported.iter().any(|s| s.as_ref() == default.as_ref()) {
329            supported.push(default);
330        }
331    }
332    supported
333}