Skip to main content

use_locale_match/
lib.rs

1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use use_locale_tag::{LocaleTagParts, normalize_locale_tag, parse_locale_tag_parts};
5
6/// A normalized locale preference with an explicit priority.
7#[derive(Clone, Debug, Eq, PartialEq)]
8pub struct LocalePreference {
9    pub locale: String,
10    pub priority: usize,
11}
12
13impl LocalePreference {
14    /// Parses and normalizes a locale preference.
15    #[must_use]
16    pub fn new(locale: &str, priority: usize) -> Option<Self> {
17        normalize_locale_tag(locale).map(|locale| Self { locale, priority })
18    }
19}
20
21/// A simple locale match result.
22#[derive(Clone, Debug, Eq, PartialEq)]
23pub struct LocaleMatch {
24    pub requested: String,
25    pub available: String,
26    pub fallback_index: usize,
27}
28
29impl LocaleMatch {
30    /// Returns `true` when the match was exact.
31    #[must_use]
32    pub const fn is_exact(&self) -> bool {
33        self.fallback_index == 0
34    }
35}
36
37/// Builds a most-specific to least-specific fallback chain for a locale tag.
38#[must_use]
39pub fn fallback_chain(input: &str) -> Vec<String> {
40    let Some(mut parts) = parse_locale_tag_parts(input) else {
41        return Vec::new();
42    };
43
44    let mut chain = Vec::new();
45    push_unique_tag(&mut chain, &parts);
46
47    if parts.private_use.take().is_some() {
48        push_unique_tag(&mut chain, &parts);
49    }
50
51    while parts.extensions.pop().is_some() {
52        push_unique_tag(&mut chain, &parts);
53    }
54
55    while parts.variants.pop().is_some() {
56        push_unique_tag(&mut chain, &parts);
57    }
58
59    if parts.region.take().is_some() {
60        push_unique_tag(&mut chain, &parts);
61    }
62
63    if parts.script.take().is_some() {
64        push_unique_tag(&mut chain, &parts);
65    }
66
67    chain
68}
69
70/// Finds the best available locale for a requested locale.
71#[must_use]
72pub fn best_locale_match<I, S>(requested: &str, available: I) -> Option<LocaleMatch>
73where
74    I: IntoIterator<Item = S>,
75    S: AsRef<str>,
76{
77    let chain = fallback_chain(requested);
78    let requested = chain.first()?.clone();
79    let available = available
80        .into_iter()
81        .filter_map(|locale| normalize_locale_tag(locale.as_ref()))
82        .collect::<Vec<_>>();
83
84    for (fallback_index, candidate) in chain.iter().enumerate() {
85        if let Some(matched) = available.iter().find(|locale| *locale == candidate) {
86            return Some(LocaleMatch {
87                requested,
88                available: matched.clone(),
89                fallback_index,
90            });
91        }
92    }
93
94    None
95}
96
97fn push_unique_tag(chain: &mut Vec<String>, parts: &LocaleTagParts) {
98    let candidate = parts.to_tag_string();
99    if chain.last() != Some(&candidate) {
100        chain.push(candidate);
101    }
102}
103
104#[cfg(test)]
105mod tests {
106    use super::{LocalePreference, best_locale_match, fallback_chain};
107
108    #[test]
109    fn builds_expected_fallback_chains() {
110        assert_eq!(fallback_chain("en-US"), vec!["en-US", "en"]);
111        assert_eq!(
112            fallback_chain("zh-Hant-TW"),
113            vec!["zh-Hant-TW", "zh-Hant", "zh"]
114        );
115    }
116
117    #[test]
118    fn removes_suffixes_before_core_subtags() {
119        assert_eq!(
120            fallback_chain("en-US-oxendict-u-ca-gregory-x-app"),
121            vec![
122                "en-US-oxendict-u-ca-gregory-x-app",
123                "en-US-oxendict-u-ca-gregory",
124                "en-US-oxendict",
125                "en-US",
126                "en",
127            ]
128        );
129    }
130
131    #[test]
132    fn best_match_uses_fallback_order() {
133        let matched = best_locale_match("en-US", ["en", "fr"]).unwrap();
134
135        assert_eq!(matched.requested, "en-US");
136        assert_eq!(matched.available, "en");
137        assert_eq!(matched.fallback_index, 1);
138        assert!(!matched.is_exact());
139    }
140
141    #[test]
142    fn exact_matches_win() {
143        let matched = best_locale_match("en-US", ["en", "en-us"]).unwrap();
144
145        assert_eq!(matched.available, "en-US");
146        assert!(matched.is_exact());
147    }
148
149    #[test]
150    fn invalid_requested_locale_has_no_match() {
151        assert!(best_locale_match("not_a_locale", ["en"]).is_none());
152        assert!(fallback_chain("not_a_locale").is_empty());
153    }
154
155    #[test]
156    fn builds_normalized_preferences() {
157        let preference = LocalePreference::new("ZH-hant-tw", 0).unwrap();
158
159        assert_eq!(preference.locale, "zh-Hant-TW");
160        assert_eq!(preference.priority, 0);
161    }
162}