Skip to main content

finance_query/translation/
lang.rs

1//! BCP 47 language tag parsing and mapping to translation targets.
2
3use crate::error::{FinanceError, Result};
4
5/// A parsed, normalized target language for translation.
6///
7/// Accepts BCP 47 language tags (e.g. `"ja"`, `"de-DE"`, `"zh-Hans"`, `"pt_BR"`).
8/// Underscores are accepted as subtag separators for convenience.
9#[derive(Debug, Clone, PartialEq, Eq, Hash)]
10#[non_exhaustive]
11pub struct Lang {
12    primary: String,
13    script: Option<String>,
14    region: Option<String>,
15}
16
17impl Lang {
18    /// Parse a BCP 47 language tag into a normalized [`Lang`].
19    ///
20    /// Returns an error if the tag is structurally invalid (the primary
21    /// subtag must be 2–3 ASCII letters).
22    pub fn parse(tag: &str) -> Result<Self> {
23        let mut primary = None;
24        let mut script = None;
25        let mut region = None;
26        for (i, part) in tag.split(['-', '_']).enumerate() {
27            if i == 0 {
28                if !(2..=3).contains(&part.len()) || !part.chars().all(|c| c.is_ascii_alphabetic())
29                {
30                    break;
31                }
32                primary = Some(part.to_ascii_lowercase());
33            } else if part.len() == 4 && part.chars().all(|c| c.is_ascii_alphabetic()) {
34                let mut s = part.to_ascii_lowercase();
35                s[..1].make_ascii_uppercase();
36                script = Some(s);
37            } else if part.len() == 2 && part.chars().all(|c| c.is_ascii_alphabetic()) {
38                region = Some(part.to_ascii_uppercase());
39            }
40        }
41        let primary = primary.ok_or_else(|| FinanceError::InvalidParameter {
42            param: "lang".into(),
43            reason: format!("'{tag}' is not a valid BCP 47 language tag"),
44        })?;
45        Ok(Self {
46            primary,
47            script,
48            region,
49        })
50    }
51
52    /// The lowercase primary language subtag (e.g. `"ja"`).
53    pub fn primary(&self) -> &str {
54        &self.primary
55    }
56
57    /// The normalized BCP 47 tag (primary + script when relevant), used as a cache key.
58    pub fn code(&self) -> String {
59        match self.effective_script() {
60            Some(script) => format!("{}-{}", self.primary, script),
61            None => self.primary.clone(),
62        }
63    }
64
65    /// True if the target language is English (translation is a no-op).
66    pub fn is_english(&self) -> bool {
67        self.primary == "en"
68    }
69
70    /// Script subtag, defaulted for Chinese from the region when absent
71    /// (TW/HK/MO imply Traditional script).
72    fn effective_script(&self) -> Option<&str> {
73        if let Some(s) = &self.script {
74            return Some(s.as_str());
75        }
76        if self.primary == "zh" {
77            return match self.region.as_deref() {
78                Some("TW") | Some("HK") | Some("MO") => Some("Hant"),
79                _ => Some("Hans"),
80            };
81        }
82        None
83    }
84
85    /// True for languages written without spaces between words, so translated
86    /// sentence segments are rejoined without a separator.
87    #[cfg(any(feature = "translation-offline", test))]
88    pub(crate) fn joins_without_space(&self) -> bool {
89        matches!(self.primary.as_str(), "ja" | "zh" | "th" | "km" | "lo")
90    }
91}
92
93#[cfg(test)]
94mod tests {
95    use super::*;
96
97    #[test]
98    fn parses_simple_tags() {
99        let l = Lang::parse("ja").unwrap();
100        assert_eq!(l.primary(), "ja");
101        assert_eq!(l.code(), "ja");
102        assert!(!l.is_english());
103    }
104
105    #[test]
106    fn parses_region_and_underscore() {
107        assert_eq!(Lang::parse("de-DE").unwrap().code(), "de");
108        assert_eq!(Lang::parse("pt_BR").unwrap().code(), "pt");
109        assert_eq!(Lang::parse("ja-JP").unwrap().primary(), "ja");
110    }
111
112    #[test]
113    fn english_variants_are_english() {
114        for tag in ["en", "en-US", "en-GB", "en_AU"] {
115            assert!(Lang::parse(tag).unwrap().is_english());
116        }
117    }
118
119    #[test]
120    fn chinese_script_resolution() {
121        assert_eq!(Lang::parse("zh").unwrap().code(), "zh-Hans");
122        assert_eq!(Lang::parse("zh-CN").unwrap().code(), "zh-Hans");
123        assert_eq!(Lang::parse("zh-TW").unwrap().code(), "zh-Hant");
124        assert_eq!(Lang::parse("zh-HK").unwrap().code(), "zh-Hant");
125        assert_eq!(Lang::parse("zh-Hant-HK").unwrap().code(), "zh-Hant");
126        assert_eq!(Lang::parse("zh-Hans").unwrap().code(), "zh-Hans");
127    }
128
129    #[test]
130    fn invalid_tags_error() {
131        assert!(Lang::parse("").is_err());
132        assert!(Lang::parse("x").is_err());
133        assert!(Lang::parse("1234").is_err());
134    }
135
136    #[test]
137    fn cjk_join_detection() {
138        assert!(Lang::parse("ja").unwrap().joins_without_space());
139        assert!(Lang::parse("zh-TW").unwrap().joins_without_space());
140        assert!(!Lang::parse("ko").unwrap().joins_without_space());
141        assert!(!Lang::parse("de").unwrap().joins_without_space());
142    }
143}