Skip to main content

improv_core_model/
locale.rs

1//! Locale-aware formula entry: the separators and translated function names
2//! a locale's users type, mapped onto the one canonical, locale-independent
3//! formula language before parsing (`.` decimal, `,` argument separator,
4//! English names, as formulas are stored).
5//!
6//! ```text
7//! de:  WENN(Preis > 1,5; RUNDEN(Preis; 2); 0)
8//!  ⇒   IF(Preis > 1.5, ROUND(Preis, 2), 0)
9//! ```
10//!
11//! The canonical spelling is always accepted too, so a formula written in
12//! the documentation works in every locale. [`Locale::localize`] goes the
13//! other way, to show a stored formula in the user's spelling.
14//!
15//! Translations come from LibreOffice's (`locale/<tag>.tsv`, one
16//! `CANONICAL<TAB>LOCAL` row per function whose name differs).
17
18/// A formula locale.
19#[derive(Debug, Clone, PartialEq)]
20pub struct Locale {
21    pub tag: &'static str,
22    /// The decimal separator (`.` or `,`).
23    pub decimal: char,
24    /// The function argument separator (`,` or `;`).
25    pub arg_sep: char,
26    /// `(canonical, local)` function names, local names upper case.
27    names: Vec<(&'static str, &'static str)>,
28}
29
30impl Locale {
31    /// The canonical locale: English names, `.` decimal, `,` separator.
32    pub fn en() -> Locale {
33        Locale {
34            tag: "en",
35            decimal: '.',
36            arg_sep: ',',
37            names: Vec::new(),
38        }
39    }
40
41    /// German (`de`): `,` decimal, `;` separator, LibreOffice's names.
42    pub fn de() -> Locale {
43        Locale {
44            tag: "de",
45            decimal: ',',
46            arg_sep: ';',
47            names: table(include_str!("locale/de.tsv")),
48        }
49    }
50
51    /// The locale for a tag such as `de`, `de_DE.UTF-8` or `en-US`; English
52    /// for anything unknown.
53    pub fn from_tag(tag: &str) -> Locale {
54        let lang = tag
55            .split(['_', '-', '.', '@'])
56            .next()
57            .unwrap_or("")
58            .to_ascii_lowercase();
59        match lang.as_str() {
60            "de" => Locale::de(),
61            _ => Locale::en(),
62        }
63    }
64
65    /// The formula locale named by the environment: `IMPROV_LOCALE`, else
66    /// the OS locale (`LC_ALL`, `LC_MESSAGES`, `LANG`).
67    pub fn from_env() -> Locale {
68        ["IMPROV_LOCALE", "LC_ALL", "LC_MESSAGES", "LANG"]
69            .iter()
70            .find_map(|v| std::env::var(v).ok().filter(|s| !s.is_empty()))
71            .map_or_else(Locale::en, |t| Locale::from_tag(&t))
72    }
73
74    /// The canonical name a local function name means (case-insensitive).
75    pub fn canonical_name(&self, local: &str) -> Option<&'static str> {
76        self.names
77            .iter()
78            .find(|(_, l)| l.eq_ignore_ascii_case(local) || eq_upper(l, local))
79            .map(|(c, _)| *c)
80    }
81
82    /// The local name of canonical function `name` (itself if untranslated).
83    pub fn local_name<'a>(&self, name: &'a str) -> &'a str {
84        self.names
85            .iter()
86            .find(|(c, _)| c.eq_ignore_ascii_case(name))
87            .map_or(name, |(_, l)| l)
88    }
89
90    /// Rewrite formula text typed in this locale into the canonical
91    /// language: numbers' decimal separator to `.`, the argument separator to
92    /// `,`, and translated function names (a word directly before `(`) to
93    /// their canonical names. Text in double quotes, quoted names in single
94    /// quotes and date literals in `#…#` are left exactly as typed.
95    pub fn canonicalize(&self, text: &str) -> String {
96        if self.tag == "en" {
97            return text.to_string();
98        }
99        self.rewrite(
100            text,
101            |w| self.canonical_name(w),
102            self.decimal,
103            '.',
104            self.arg_sep,
105            ',',
106        )
107    }
108
109    /// The reverse of [`Self::canonicalize`]: canonical formula text in this
110    /// locale's spelling.
111    pub fn localize(&self, text: &str) -> String {
112        if self.tag == "en" {
113            return text.to_string();
114        }
115        let local = |w: &str| {
116            crate::functions::by_name(w)
117                .next()
118                .map(|d| self.local_name(d.name))
119        };
120        self.rewrite(text, local, '.', self.decimal, ',', self.arg_sep)
121    }
122
123    fn rewrite<'n>(
124        &self,
125        text: &str,
126        name: impl Fn(&str) -> Option<&'n str>,
127        dec_from: char,
128        dec_to: char,
129        sep_from: char,
130        sep_to: char,
131    ) -> String {
132        let chars: Vec<char> = text.chars().collect();
133        let mut out = String::with_capacity(text.len());
134        let mut i = 0;
135        while i < chars.len() {
136            let c = chars[i];
137            match c {
138                // Quoted text, quoted names and dates pass through untouched.
139                '"' | '\'' | '#' => {
140                    let close = chars[i + 1..].iter().position(|x| *x == c);
141                    let end = close.map_or(chars.len(), |p| i + 1 + p + 1);
142                    out.extend(&chars[i..end]);
143                    i = end;
144                }
145                c if c.is_ascii_digit() => {
146                    // A number: digits, then one decimal separator followed by
147                    // a digit.
148                    while i < chars.len() && chars[i].is_ascii_digit() {
149                        out.push(chars[i]);
150                        i += 1;
151                    }
152                    if i + 1 < chars.len() && chars[i] == dec_from && chars[i + 1].is_ascii_digit()
153                    {
154                        out.push(dec_to);
155                        i += 1;
156                        while i < chars.len() && chars[i].is_ascii_digit() {
157                            out.push(chars[i]);
158                            i += 1;
159                        }
160                    }
161                }
162                c if c.is_alphabetic() || c == '_' => {
163                    let start = i;
164                    while i < chars.len()
165                        && (chars[i].is_alphanumeric() || chars[i] == '_' || chars[i] == '.')
166                    {
167                        i += 1;
168                    }
169                    let word: String = chars[start..i].iter().collect();
170                    let mut j = i;
171                    while j < chars.len() && chars[j] == ' ' {
172                        j += 1;
173                    }
174                    let is_call = chars.get(j) == Some(&'(');
175                    match name(&word).filter(|_| is_call) {
176                        Some(n) => out.push_str(n),
177                        None => out.push_str(&word),
178                    }
179                }
180                c if c == sep_from => {
181                    out.push(sep_to);
182                    i += 1;
183                }
184                c => {
185                    out.push(c);
186                    i += 1;
187                }
188            }
189        }
190        out
191    }
192}
193
194/// Case-insensitive equality that also folds non-ASCII (`KÜRZEN`/`kürzen`).
195fn eq_upper(a: &str, b: &str) -> bool {
196    a.chars()
197        .flat_map(char::to_uppercase)
198        .eq(b.chars().flat_map(char::to_uppercase))
199}
200
201fn table(tsv: &'static str) -> Vec<(&'static str, &'static str)> {
202    tsv.lines()
203        .skip(1)
204        .filter_map(|l| l.split_once('\t'))
205        .collect()
206}
207
208#[cfg(test)]
209#[cfg_attr(coverage_nightly, coverage(off))]
210mod tests {
211    use super::*;
212
213    #[test]
214    fn german_entry_round_trips() {
215        let de = Locale::de();
216        let typed = "WENN(Preis > 1,5; RUNDEN(Preis; 2); kürzen(Preis))";
217        let canon = de.canonicalize(typed);
218        assert_eq!(canon, "IF(Preis > 1.5, ROUND(Preis, 2), TRUNC(Preis))");
219        assert_eq!(
220            de.localize(&canon),
221            "WENN(Preis > 1,5; RUNDEN(Preis; 2); KÜRZEN(Preis))"
222        );
223        // Dotted names, and canonical names typed in a German locale.
224        assert_eq!(
225            de.canonicalize("UNTERGRENZE.MATHEMATIK(x; 2)"),
226            "FLOOR.MATH(x, 2)"
227        );
228        assert_eq!(de.canonicalize("ROUND(x; 2)"), "ROUND(x, 2)");
229        // Quoted text, names and dates are untouched; a measure named like a
230        // function (no "(") is not translated.
231        assert_eq!(
232            de.canonicalize(r#"VERKETTEN("1,5; WENN("; 'Summe 1,5'; #2025-01-01#) & SUMME"#),
233            r#"CONCATENATE("1,5; WENN(", 'Summe 1,5', #2025-01-01#) & SUMME"#
234        );
235        assert_eq!(de.local_name("MIN"), "MIN");
236        assert_eq!(de.canonical_name("summe"), Some("SUM"));
237    }
238
239    #[test]
240    fn english_is_identity_and_tags_resolve() {
241        let en = Locale::en();
242        assert_eq!(en.canonicalize("ROUND(1.5, 0)"), "ROUND(1.5, 0)");
243        assert_eq!(en.localize("ROUND(1.5, 0)"), "ROUND(1.5, 0)");
244        assert_eq!(Locale::from_tag("de_DE.UTF-8").tag, "de");
245        assert_eq!(Locale::from_tag("de-CH").tag, "de");
246        assert_eq!(Locale::from_tag("fr_FR").tag, "en");
247        assert_eq!(Locale::from_tag("").tag, "en");
248    }
249
250    #[test]
251    fn every_translation_names_a_function_or_a_planned_one() {
252        let de = Locale::de();
253        assert!(de.names.len() > 300);
254        for name in ["SUM", "IF", "ROUND", "LEFT", "MOD", "POWER", "CEILING"] {
255            assert_ne!(de.local_name(name), name, "{name} has a German name");
256        }
257    }
258}