1#[derive(Debug, Clone, PartialEq)]
20pub struct Locale {
21 pub tag: &'static str,
22 pub decimal: char,
24 pub arg_sep: char,
26 names: Vec<(&'static str, &'static str)>,
28}
29
30impl Locale {
31 pub fn en() -> Locale {
33 Locale {
34 tag: "en",
35 decimal: '.',
36 arg_sep: ',',
37 names: Vec::new(),
38 }
39 }
40
41 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 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 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 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 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 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 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 '"' | '\'' | '#' => {
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 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
194fn 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 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 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}