1use std::borrow::Cow;
2use std::collections::HashMap;
3
4pub struct NamespacedBackend {
6 backend: &'static dyn Backend,
7 namespace: &'static str,
8}
9
10impl NamespacedBackend {
11 pub fn new(backend: &'static dyn Backend, namespace: &'static str) -> Self {
13 Self { backend, namespace }
14 }
15
16 fn namespaced_key(&self, key: &str) -> String {
17 format!("{}.{key}", self.namespace)
18 }
19}
20
21impl Backend for NamespacedBackend {
22 fn available_locales(&self) -> Vec<Cow<'_, str>> {
23 self.backend
24 .available_locales()
25 .into_iter()
26 .filter(|locale| self.messages_for_locale(locale).is_some())
27 .collect()
28 }
29
30 fn translate(&self, locale: &str, key: &str) -> Option<Cow<'_, str>> {
31 self.backend.translate(locale, &self.namespaced_key(key))
32 }
33
34 fn messages_for_locale(&self, locale: &str) -> Option<Vec<(Cow<'_, str>, Cow<'_, str>)>> {
35 let prefix = format!("{}.", self.namespace);
36 let messages = self
37 .backend
38 .messages_for_locale(locale)?
39 .into_iter()
40 .filter_map(|(key, value)| {
41 key.strip_prefix(&prefix)
42 .map(|key| (Cow::Owned(key.to_string()), value))
43 })
44 .collect::<Vec<_>>();
45
46 (!messages.is_empty()).then_some(messages)
47 }
48}
49
50pub trait Backend: Send + Sync + 'static {
52 fn available_locales(&self) -> Vec<Cow<'_, str>>;
54 fn translate(&self, locale: &str, key: &str) -> Option<Cow<'_, str>>;
56 fn messages_for_locale(&self, locale: &str) -> Option<Vec<(Cow<'_, str>, Cow<'_, str>)>>;
58}
59
60pub trait BackendExt: Backend {
61 fn extend<T: Backend>(self, other: T) -> CombinedBackend<Self, T>
63 where
64 Self: Sized,
65 {
66 CombinedBackend(self, other)
67 }
68}
69
70pub struct CombinedBackend<A, B>(A, B);
71
72impl<A, B> Backend for CombinedBackend<A, B>
73where
74 A: Backend,
75 B: Backend,
76{
77 fn available_locales(&self) -> Vec<Cow<'_, str>> {
78 let mut available_locales = self.0.available_locales();
79 for locale in self.1.available_locales() {
80 if !available_locales.contains(&locale) {
81 available_locales.push(locale);
82 }
83 }
84 available_locales
85 }
86
87 #[inline]
88 fn translate(&self, locale: &str, key: &str) -> Option<Cow<'_, str>> {
89 self.1
90 .translate(locale, key)
91 .or_else(|| self.0.translate(locale, key))
92 }
93
94 fn messages_for_locale(&self, locale: &str) -> Option<Vec<(Cow<'_, str>, Cow<'_, str>)>> {
95 match (
96 self.1.messages_for_locale(locale),
97 self.0.messages_for_locale(locale),
98 ) {
99 (None, None) => None,
100 (None, a) => a,
101 (b, None) => b,
102 (Some(b), Some(a)) => Some(
103 b.into_iter()
104 .chain(
105 a.into_iter()
106 .filter(|(k, _)| self.1.translate(locale, k).is_none()),
107 )
108 .collect(),
109 ),
110 }
111 }
112}
113
114pub struct SimpleBackend {
116 translations: LocaleTranslations,
118}
119
120type FastMap<K, V> = HashMap<K, V, foldhash::fast::RandomState>;
123type Messages = FastMap<Cow<'static, str>, Cow<'static, str>>;
124type InputMessages = HashMap<Cow<'static, str>, Cow<'static, str>>;
125
126const SMALL_LOCALE_LIMIT: usize = 16;
130const SMALL_PREFIX_LIMIT: usize = 4;
131
132struct SmallLocale {
133 prefix: u16,
134 locale: Cow<'static, str>,
135 messages: Messages,
136}
137
138impl SmallLocale {
139 fn new(locale: Cow<'static, str>, messages: Messages) -> Self {
140 Self {
141 prefix: locale_prefix(&locale),
142 locale,
143 messages,
144 }
145 }
146}
147
148fn locale_prefix(locale: &str) -> u16 {
151 let bytes = locale.as_bytes();
152 u16::from_be_bytes([*bytes.first().unwrap_or(&0), *bytes.get(1).unwrap_or(&0)])
153}
154
155enum LocaleTranslations {
156 Small(Vec<SmallLocale>),
157 Large(FastMap<Cow<'static, str>, Messages>),
158}
159
160impl LocaleTranslations {
161 fn from_map(map: HashMap<Cow<'static, str>, InputMessages>) -> Self {
162 let map = map
163 .into_iter()
164 .map(|(locale, messages)| (locale, messages.into_iter().collect::<Messages>()))
165 .collect::<FastMap<_, _>>();
166 if map.len() <= SMALL_LOCALE_LIMIT {
167 let mut entries = map
168 .into_iter()
169 .map(|(locale, messages)| SmallLocale::new(locale, messages))
170 .collect::<Vec<_>>();
171 entries.sort_by(|a, b| {
172 a.prefix
173 .cmp(&b.prefix)
174 .then_with(|| a.locale.cmp(&b.locale))
175 });
176 if entries
177 .windows(SMALL_PREFIX_LIMIT + 1)
178 .any(|group| group.first().unwrap().prefix == group.last().unwrap().prefix)
179 {
180 return Self::Large(
181 entries
182 .into_iter()
183 .map(|entry| (entry.locale, entry.messages))
184 .collect(),
185 );
186 }
187 Self::Small(entries)
188 } else {
189 Self::Large(map)
190 }
191 }
192
193 fn get(&self, locale: &str) -> Option<&Messages> {
194 match self {
195 Self::Small(entries) => {
196 let prefix = locale_prefix(locale);
197 entries
198 .iter()
199 .find(|entry| entry.prefix == prefix && entry.locale.as_ref() == locale)
200 .map(|entry| &entry.messages)
201 }
202 Self::Large(entries) => entries.get(locale),
203 }
204 }
205
206 fn add(&mut self, locale: Cow<'static, str>, data: InputMessages) {
207 let data = data.into_iter().collect::<Messages>();
208 match self {
209 Self::Small(entries) => {
210 let prefix = locale_prefix(&locale);
211 let start = entries.partition_point(|entry| entry.prefix < prefix);
212 let existing = entries[start..]
213 .iter()
214 .take_while(|entry| entry.prefix == prefix)
215 .position(|entry| entry.locale.as_ref() == locale.as_ref());
216 if let Some(index) = existing {
217 entries[start + index].messages.extend(data);
218 } else if entries.len() < SMALL_LOCALE_LIMIT
219 && entries[start..]
220 .iter()
221 .take_while(|entry| entry.prefix == prefix)
222 .count()
223 < SMALL_PREFIX_LIMIT
224 {
225 entries.insert(start, SmallLocale::new(locale, data));
226 } else {
227 let mut map = std::mem::take(entries)
228 .into_iter()
229 .map(|entry| (entry.locale, entry.messages))
230 .collect::<FastMap<_, _>>();
231 map.insert(locale, data);
232 *self = Self::Large(map);
233 }
234 }
235 Self::Large(entries) => entries.entry(locale).or_default().extend(data),
236 }
237 }
238
239 fn available_locales(&self) -> Vec<Cow<'_, str>> {
240 let mut locales: Vec<_> = match self {
241 Self::Small(entries) => entries.iter().map(|entry| entry.locale.clone()).collect(),
242 Self::Large(entries) => entries.keys().cloned().collect(),
243 };
244 locales.sort();
245 locales
246 }
247}
248
249impl
250 FromIterator<(
251 Cow<'static, str>,
252 HashMap<Cow<'static, str>, Cow<'static, str>>,
253 )> for SimpleBackend
254{
255 fn from_iter<
256 I: IntoIterator<
257 Item = (
258 Cow<'static, str>,
259 HashMap<Cow<'static, str>, Cow<'static, str>>,
260 ),
261 >,
262 >(
263 iter: I,
264 ) -> Self {
265 Self {
266 translations: LocaleTranslations::from_map(iter.into_iter().collect()),
269 }
270 }
271}
272
273impl SimpleBackend {
274 pub fn new() -> Self {
276 SimpleBackend {
277 translations: LocaleTranslations::Small(Vec::new()),
278 }
279 }
280
281 pub fn add_translations(
293 &mut self,
294 locale: Cow<'static, str>,
295 data: HashMap<Cow<'static, str>, Cow<'static, str>>,
296 ) {
297 self.translations.add(locale, data);
298 }
299}
300
301impl Backend for SimpleBackend {
302 fn available_locales(&self) -> Vec<Cow<'_, str>> {
303 self.translations.available_locales()
304 }
305
306 fn translate(&self, locale: &str, key: &str) -> Option<Cow<'_, str>> {
307 if let Some(trs) = self.translations.get(locale) {
308 return trs.get(key).cloned();
309 }
310
311 None
312 }
313
314 fn messages_for_locale(&self, locale: &str) -> Option<Vec<(Cow<'_, str>, Cow<'_, str>)>> {
315 self.translations
316 .get(locale)
317 .map(|trs| trs.iter().map(|(k, v)| (k.clone(), v.clone())).collect())
318 }
319}
320
321impl BackendExt for SimpleBackend {}
322
323impl Default for SimpleBackend {
324 fn default() -> Self {
325 Self::new()
326 }
327}
328
329#[cfg(test)]
330mod tests {
331 use std::borrow::Cow;
332 use std::collections::HashMap;
333
334 use super::SimpleBackend;
335 use super::{Backend, BackendExt, LocaleTranslations, NamespacedBackend, SMALL_LOCALE_LIMIT};
336
337 #[test]
338 fn small_locale_storage_transitions_and_keeps_merged_messages() {
339 let mut backend = SimpleBackend::new();
340 for index in 0..SMALL_LOCALE_LIMIT {
341 let locale = format!("{index:02}-locale");
342 backend.add_translations(
343 Cow::Owned(locale),
344 HashMap::from([(Cow::Borrowed("first"), Cow::Borrowed("value"))]),
345 );
346 }
347 assert!(matches!(
348 &backend.translations,
349 LocaleTranslations::Small(_)
350 ));
351
352 backend.add_translations(
353 Cow::Borrowed("00-locale"),
354 HashMap::from([(Cow::Borrowed("second"), Cow::Borrowed("another"))]),
355 );
356 assert_eq!(
357 backend.translate("00-locale", "first"),
358 Some(Cow::Borrowed("value"))
359 );
360 assert_eq!(
361 backend.translate("00-locale", "second"),
362 Some(Cow::Borrowed("another"))
363 );
364
365 backend.add_translations(
366 Cow::Borrowed("extra"),
367 HashMap::from([(Cow::Borrowed("first"), Cow::Borrowed("extra value"))]),
368 );
369 assert!(matches!(
370 &backend.translations,
371 LocaleTranslations::Large(_)
372 ));
373 assert_eq!(
374 backend.translate("00-locale", "second"),
375 Some(Cow::Borrowed("another"))
376 );
377 assert_eq!(
378 backend.translate("extra", "first"),
379 Some(Cow::Borrowed("extra value"))
380 );
381 assert_eq!(backend.messages_for_locale("missing"), None);
382 assert_eq!(backend.available_locales().len(), SMALL_LOCALE_LIMIT + 1);
383 }
384
385 #[test]
386 fn from_iterator_duplicate_locale_uses_last_entry() {
387 let first = HashMap::from([(Cow::Borrowed("key"), Cow::Borrowed("old"))]);
388 let second = HashMap::from([(Cow::Borrowed("key"), Cow::Borrowed("new"))]);
389 let backend =
390 SimpleBackend::from_iter([(Cow::Borrowed("en"), first), (Cow::Borrowed("en"), second)]);
391
392 assert!(matches!(
393 &backend.translations,
394 LocaleTranslations::Small(_)
395 ));
396 assert_eq!(backend.available_locales(), vec!["en"]);
397 assert_eq!(backend.translate("en", "key"), Some(Cow::Borrowed("new")));
398 }
399
400 #[test]
401 fn small_locale_index_checks_full_name_after_prefix_collision() {
402 let mut backend = SimpleBackend::new();
403 for (locale, value) in [
404 ("", "empty"),
405 ("\0", "nul"),
406 ("a", "short"),
407 ("a\0", "short nul"),
408 ("zh", "chinese"),
409 ("zh-CN", "simplified"),
410 ("é", "accent"),
411 ("é-x", "accent extended"),
412 ] {
413 backend.add_translations(
414 Cow::Borrowed(locale),
415 HashMap::from([(Cow::Borrowed("key"), Cow::Borrowed(value))]),
416 );
417 }
418
419 assert!(matches!(
420 &backend.translations,
421 LocaleTranslations::Small(_)
422 ));
423 for (locale, expected) in [
424 ("", "empty"),
425 ("\0", "nul"),
426 ("a", "short"),
427 ("a\0", "short nul"),
428 ("zh", "chinese"),
429 ("zh-CN", "simplified"),
430 ("é", "accent"),
431 ("é-x", "accent extended"),
432 ] {
433 assert_eq!(
434 backend.translate(locale, "key"),
435 Some(Cow::Borrowed(expected))
436 );
437 }
438 for missing in ["\0\0", "a\0x", "zh-TW", "é-y"] {
439 assert_eq!(backend.translate(missing, "key"), None);
440 }
441 }
442
443 #[test]
444 fn adding_fifth_shared_prefix_promotes_to_hash_map() {
445 let mut backend = SimpleBackend::new();
446 for index in 0..4 {
447 backend.add_translations(
448 Cow::Owned(format!("en-{index}")),
449 HashMap::from([(Cow::Borrowed("key"), Cow::Owned(index.to_string()))]),
450 );
451 }
452 assert!(matches!(
453 &backend.translations,
454 LocaleTranslations::Small(_)
455 ));
456
457 backend.add_translations(
458 Cow::Borrowed("en-0"),
459 HashMap::from([(Cow::Borrowed("extra"), Cow::Borrowed("merged"))]),
460 );
461 assert!(matches!(
462 &backend.translations,
463 LocaleTranslations::Small(_)
464 ));
465
466 backend.add_translations(
467 Cow::Borrowed("en-4"),
468 HashMap::from([(Cow::Borrowed("key"), Cow::Borrowed("four"))]),
469 );
470 assert!(matches!(
471 &backend.translations,
472 LocaleTranslations::Large(_)
473 ));
474 assert_eq!(
475 backend.translate("en-0", "extra"),
476 Some(Cow::Borrowed("merged"))
477 );
478 assert_eq!(
479 backend.translate("en-4", "key"),
480 Some(Cow::Borrowed("four"))
481 );
482 assert_eq!(backend.translate("en-missing", "key"), None);
483 assert_eq!(backend.available_locales().len(), 5);
484 }
485
486 #[test]
487 fn from_iterator_with_five_shared_prefixes_uses_hash_map() {
488 let backend = SimpleBackend::from_iter((0..5).map(|index| {
489 (
490 Cow::Owned(format!("en-{index}")),
491 HashMap::from([(Cow::Borrowed("key"), Cow::Owned(index.to_string()))]),
492 )
493 }));
494
495 assert!(matches!(
496 &backend.translations,
497 LocaleTranslations::Large(_)
498 ));
499 assert_eq!(backend.translate("en-4", "key"), Some(Cow::Borrowed("4")));
500 assert_eq!(backend.translate("en-missing", "key"), None);
501 }
502
503 #[test]
504 fn test_simple_backend() {
505 let mut backend = SimpleBackend::new();
506 let mut data = HashMap::new();
507 data.insert("hello".into(), "Hello".into());
508 data.insert("foo".into(), "Foo bar".into());
509 backend.add_translations("en".into(), data);
510
511 let mut data_cn = HashMap::new();
512 data_cn.insert("hello".into(), "你好".into());
513 data_cn.insert("foo".into(), "Foo 测试".into());
514 backend.add_translations("zh-CN".into(), data_cn);
515
516 assert_eq!(backend.translate("en", "hello"), Some(Cow::from("Hello")));
517 assert_eq!(backend.translate("en", "foo"), Some(Cow::from("Foo bar")));
518 assert_eq!(backend.translate("zh-CN", "hello"), Some(Cow::from("你好")));
519 assert_eq!(
520 backend.translate("zh-CN", "foo"),
521 Some(Cow::from("Foo 测试"))
522 );
523
524 assert_eq!(backend.available_locales(), vec!["en", "zh-CN"]);
525 }
526
527 #[test]
528 fn test_combined_backend() {
529 let mut backend = SimpleBackend::new();
530 let mut data = HashMap::new();
531 data.insert("hello".into(), "Hello".into());
532 data.insert("foo".into(), "Foo bar".into());
533 backend.add_translations("en".into(), data);
534
535 let mut data_cn = HashMap::new();
536 data_cn.insert("hello".into(), "你好".into());
537 data_cn.insert("foo".into(), "Foo 测试".into());
538 backend.add_translations("zh-CN".into(), data_cn);
539
540 let mut backend2 = SimpleBackend::new();
541 let mut data2 = HashMap::new();
542 data2.insert("hello".into(), "Hello2".into());
543 backend2.add_translations("en".into(), data2);
544
545 let mut data_cn2 = HashMap::new();
546 data_cn2.insert("hello".into(), "你好2".into());
547 backend2.add_translations("zh-CN".into(), data_cn2);
548
549 let combined = backend.extend(backend2);
550 assert_eq!(combined.translate("en", "hello"), Some(Cow::from("Hello2")));
551 assert_eq!(
552 combined.translate("zh-CN", "hello"),
553 Some(Cow::from("你好2"))
554 );
555
556 assert_eq!(combined.available_locales(), vec!["en", "zh-CN"]);
557 }
558
559 #[test]
560 fn test_namespaced_backend() {
561 let mut backend = SimpleBackend::new();
562 let mut data = HashMap::new();
563 data.insert("ui_component.title".into(), "Custom title".into());
564 data.insert("title".into(), "Unrelated title".into());
565 backend.add_translations("en".into(), data);
566
567 let backend = Box::leak(Box::new(backend));
568 let namespaced = NamespacedBackend::new(backend, "ui_component");
569
570 assert_eq!(
571 namespaced.translate("en", "title"),
572 Some(Cow::Borrowed("Custom title"))
573 );
574 assert_eq!(
575 namespaced.messages_for_locale("en"),
576 Some(vec![(
577 Cow::Owned("title".to_string()),
578 Cow::Borrowed("Custom title")
579 )])
580 );
581 }
582}