Another project
1use core::fmt;
2use std::collections::HashMap;
3use std::sync::LazyLock;
4
5use serde::Serialize;
6
7use crate::layout::LayoutDirection;
8
9#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize)]
10#[serde(transparent)]
11pub struct StringKey(&'static str);
12
13impl StringKey {
14 #[must_use]
15 pub const fn new(id: &'static str) -> Self {
16 Self(id)
17 }
18
19 #[must_use]
20 pub const fn id(self) -> &'static str {
21 self.0
22 }
23}
24
25impl fmt::Display for StringKey {
26 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
27 f.write_str(self.0)
28 }
29}
30
31#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, Serialize)]
32pub enum Locale {
33 EnUs,
34 ArXb,
35}
36
37impl Locale {
38 pub const DEFAULT: Self = Self::EnUs;
39
40 #[must_use]
41 pub const fn as_bcp47(self) -> &'static str {
42 match self {
43 Self::EnUs => "en-US",
44 Self::ArXb => "ar-XB",
45 }
46 }
47
48 #[must_use]
49 pub const fn direction(self) -> LayoutDirection {
50 match self {
51 Self::EnUs => LayoutDirection::Ltr,
52 Self::ArXb => LayoutDirection::Rtl,
53 }
54 }
55
56 #[must_use]
57 pub const fn plural_category(self, n: u64) -> PluralCategory {
58 match self {
59 Self::EnUs => english_plural(n),
60 Self::ArXb => arabic_plural(n),
61 }
62 }
63}
64
65impl Default for Locale {
66 fn default() -> Self {
67 Self::DEFAULT
68 }
69}
70
71#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, Serialize)]
72pub enum PluralCategory {
73 Zero,
74 One,
75 Two,
76 Few,
77 Many,
78 Other,
79}
80
81const fn english_plural(n: u64) -> PluralCategory {
82 match n {
83 1 => PluralCategory::One,
84 _ => PluralCategory::Other,
85 }
86}
87
88const fn arabic_plural(n: u64) -> PluralCategory {
89 match (n, n % 100) {
90 (0, _) => PluralCategory::Zero,
91 (1, _) => PluralCategory::One,
92 (2, _) => PluralCategory::Two,
93 (_, 3..=10) => PluralCategory::Few,
94 (_, 11..=99) => PluralCategory::Many,
95 _ => PluralCategory::Other,
96 }
97}
98
99#[derive(Clone, Debug, Default, PartialEq, Eq)]
100pub struct PluralEntry {
101 pub other: String,
102 pub zero: Option<String>,
103 pub one: Option<String>,
104 pub two: Option<String>,
105 pub few: Option<String>,
106 pub many: Option<String>,
107}
108
109impl PluralEntry {
110 #[must_use]
111 pub fn other(other: impl Into<String>) -> Self {
112 Self::default().with(PluralCategory::Other, other)
113 }
114
115 #[must_use]
116 pub fn new(one: impl Into<String>, other: impl Into<String>) -> Self {
117 Self::other(other).with(PluralCategory::One, one)
118 }
119
120 #[must_use]
121 pub fn with(mut self, category: PluralCategory, value: impl Into<String>) -> Self {
122 let value = value.into();
123 match category {
124 PluralCategory::Zero => self.zero = Some(value),
125 PluralCategory::One => self.one = Some(value),
126 PluralCategory::Two => self.two = Some(value),
127 PluralCategory::Few => self.few = Some(value),
128 PluralCategory::Many => self.many = Some(value),
129 PluralCategory::Other => self.other = value,
130 }
131 self
132 }
133
134 fn pick(&self, category: PluralCategory) -> &str {
135 let variant = match category {
136 PluralCategory::Zero => self.zero.as_deref(),
137 PluralCategory::One => self.one.as_deref(),
138 PluralCategory::Two => self.two.as_deref(),
139 PluralCategory::Few => self.few.as_deref(),
140 PluralCategory::Many => self.many.as_deref(),
141 PluralCategory::Other => return &self.other,
142 };
143 variant.unwrap_or(&self.other)
144 }
145}
146
147#[derive(Clone, Debug)]
148pub struct StringTable {
149 locale: Locale,
150 entries: HashMap<StringKey, String>,
151 plural_entries: HashMap<StringKey, PluralEntry>,
152}
153
154impl Default for StringTable {
155 fn default() -> Self {
156 Self::for_locale(Locale::DEFAULT)
157 }
158}
159
160impl StringTable {
161 #[must_use]
162 pub fn new() -> Self {
163 Self::default()
164 }
165
166 #[must_use]
167 pub fn for_locale(locale: Locale) -> Self {
168 Self {
169 locale,
170 entries: HashMap::new(),
171 plural_entries: HashMap::new(),
172 }
173 }
174
175 #[must_use]
176 pub fn empty() -> &'static Self {
177 static EMPTY: LazyLock<StringTable> = LazyLock::new(StringTable::default);
178 &EMPTY
179 }
180
181 #[must_use]
182 pub fn from_entries<I>(iter: I) -> Self
183 where
184 I: IntoIterator<Item = (StringKey, String)>,
185 {
186 let mut table = Self::default();
187 table.entries.extend(iter);
188 table
189 }
190
191 #[must_use]
192 pub const fn locale(&self) -> Locale {
193 self.locale
194 }
195
196 #[must_use]
197 pub const fn direction(&self) -> LayoutDirection {
198 self.locale.direction()
199 }
200
201 pub fn insert(&mut self, key: StringKey, value: String) -> Option<String> {
202 self.entries.insert(key, value)
203 }
204
205 pub fn insert_plural(&mut self, key: StringKey, plural: PluralEntry) -> Option<PluralEntry> {
206 self.plural_entries.insert(key, plural)
207 }
208
209 #[must_use]
210 pub fn resolve(&self, key: StringKey) -> &str {
211 self.entries.get(&key).map_or(key.0, String::as_str)
212 }
213
214 #[must_use]
215 pub fn resolve_plural(&self, key: StringKey, n: u64) -> &str {
216 let category = self.locale.plural_category(n);
217 self.plural_entries
218 .get(&key)
219 .map_or(key.0, |entry| entry.pick(category))
220 }
221
222 #[must_use]
223 pub fn format_count(&self, n: u64) -> String {
224 match self.locale {
225 Locale::EnUs => group_thousands(n, ','),
226 Locale::ArXb => group_thousands(n, '\u{066C}')
227 .chars()
228 .map(map_arabic_indic)
229 .collect(),
230 }
231 }
232
233 #[must_use]
234 pub fn format_plural(&self, key: StringKey, n: u64) -> String {
235 self.resolve_plural(key, n)
236 .replace("{n}", &self.format_count(n))
237 }
238
239 #[must_use]
240 pub fn contains(&self, key: StringKey) -> bool {
241 self.entries.contains_key(&key) || self.plural_entries.contains_key(&key)
242 }
243
244 #[must_use]
245 pub fn len(&self) -> usize {
246 self.entries.len() + self.plural_entries.len()
247 }
248
249 #[must_use]
250 pub fn is_empty(&self) -> bool {
251 self.entries.is_empty() && self.plural_entries.is_empty()
252 }
253}
254
255fn group_thousands(n: u64, separator: char) -> String {
256 let digits = n.to_string();
257 let head_len = digits.len() % 3;
258 let separator = separator.to_string();
259 [&digits[..head_len]]
260 .into_iter()
261 .filter(|head| !head.is_empty())
262 .chain(
263 (head_len..digits.len())
264 .step_by(3)
265 .map(|start| &digits[start..start + 3]),
266 )
267 .collect::<Vec<_>>()
268 .join(&separator)
269}
270
271fn map_arabic_indic(c: char) -> char {
272 if c.is_ascii_digit() {
273 let offset = u32::from(c) - u32::from('0');
274 char::from_u32(0x0660 + offset).unwrap_or(c)
275 } else {
276 c
277 }
278}
279
280#[cfg(test)]
281mod tests {
282 use super::{Locale, PluralCategory, PluralEntry, StringKey, StringTable};
283 use crate::layout::LayoutDirection;
284
285 const GREETING: StringKey = StringKey::new("dialog.greeting");
286 const FAREWELL: StringKey = StringKey::new("dialog.farewell");
287 const ITEMS: StringKey = StringKey::new("status.items");
288
289 #[test]
290 fn key_round_trips_id() {
291 assert_eq!(GREETING.id(), "dialog.greeting");
292 }
293
294 #[test]
295 fn key_displays_id() {
296 assert_eq!(format!("{GREETING}"), "dialog.greeting");
297 }
298
299 #[test]
300 fn distinct_keys_compare_distinct() {
301 assert_ne!(GREETING, FAREWELL);
302 assert_eq!(GREETING, StringKey::new("dialog.greeting"));
303 }
304
305 #[test]
306 fn empty_table_falls_back_to_id() {
307 let table = StringTable::new();
308 assert_eq!(table.resolve(GREETING), "dialog.greeting");
309 assert!(table.is_empty());
310 assert_eq!(table.len(), 0);
311 }
312
313 #[test]
314 fn populated_table_returns_entry() {
315 let table = StringTable::from_entries([(GREETING, "Hello".to_owned())]);
316 assert_eq!(table.resolve(GREETING), "Hello");
317 assert_eq!(table.resolve(FAREWELL), "dialog.farewell");
318 assert!(table.contains(GREETING));
319 assert!(!table.contains(FAREWELL));
320 }
321
322 #[test]
323 fn insert_replaces_existing() {
324 let mut table = StringTable::new();
325 assert!(table.insert(GREETING, "Hi".to_owned()).is_none());
326 assert_eq!(
327 table.insert(GREETING, "Hello".to_owned()),
328 Some("Hi".to_owned()),
329 );
330 assert_eq!(table.resolve(GREETING), "Hello");
331 }
332
333 #[test]
334 fn len_reflects_inserted_entries() {
335 let table = StringTable::from_entries([
336 (GREETING, "Hello".to_owned()),
337 (FAREWELL, "Bye".to_owned()),
338 ]);
339 assert_eq!(table.len(), 2);
340 assert!(!table.is_empty());
341 }
342
343 #[test]
344 fn default_locale_is_en_us_ltr() {
345 let table = StringTable::new();
346 assert_eq!(table.locale(), Locale::EnUs);
347 assert_eq!(table.direction(), LayoutDirection::Ltr);
348 }
349
350 #[test]
351 fn ar_xb_pseudo_locale_is_rtl() {
352 let table = StringTable::for_locale(Locale::ArXb);
353 assert_eq!(table.direction(), LayoutDirection::Rtl);
354 assert_eq!(table.locale().as_bcp47(), "ar-XB");
355 }
356
357 #[test]
358 fn english_plural_picks_one_or_other() {
359 assert_eq!(Locale::EnUs.plural_category(0), PluralCategory::Other);
360 assert_eq!(Locale::EnUs.plural_category(1), PluralCategory::One);
361 assert_eq!(Locale::EnUs.plural_category(2), PluralCategory::Other);
362 assert_eq!(Locale::EnUs.plural_category(42), PluralCategory::Other);
363 }
364
365 #[test]
366 fn arabic_plural_covers_every_category() {
367 assert_eq!(Locale::ArXb.plural_category(0), PluralCategory::Zero);
368 assert_eq!(Locale::ArXb.plural_category(1), PluralCategory::One);
369 assert_eq!(Locale::ArXb.plural_category(2), PluralCategory::Two);
370 assert_eq!(Locale::ArXb.plural_category(5), PluralCategory::Few);
371 assert_eq!(Locale::ArXb.plural_category(20), PluralCategory::Many);
372 assert_eq!(Locale::ArXb.plural_category(100), PluralCategory::Other);
373 assert_eq!(Locale::ArXb.plural_category(102), PluralCategory::Other);
374 assert_eq!(Locale::ArXb.plural_category(105), PluralCategory::Few);
375 }
376
377 #[test]
378 fn plural_entry_picks_variant_per_category() {
379 let mut table = StringTable::new();
380 table.insert_plural(ITEMS, PluralEntry::new("{n} item", "{n} items"));
381 assert_eq!(table.resolve_plural(ITEMS, 1), "{n} item");
382 assert_eq!(table.resolve_plural(ITEMS, 0), "{n} items");
383 assert_eq!(table.resolve_plural(ITEMS, 5), "{n} items");
384 }
385
386 #[test]
387 fn plural_entry_falls_back_to_other_when_variant_absent() {
388 let mut table = StringTable::for_locale(Locale::ArXb);
389 table.insert_plural(ITEMS, PluralEntry::new("عنصر واحد", "{n} عناصر"));
390 assert_eq!(table.resolve_plural(ITEMS, 0), "{n} عناصر");
391 assert_eq!(table.resolve_plural(ITEMS, 1), "عنصر واحد");
392 assert_eq!(table.resolve_plural(ITEMS, 5), "{n} عناصر");
393 }
394
395 #[test]
396 fn plural_entry_uses_explicit_variant_when_present() {
397 let mut table = StringTable::for_locale(Locale::ArXb);
398 table.insert_plural(
399 ITEMS,
400 PluralEntry::new("one", "other")
401 .with(PluralCategory::Zero, "zero")
402 .with(PluralCategory::Two, "two")
403 .with(PluralCategory::Few, "few")
404 .with(PluralCategory::Many, "many"),
405 );
406 assert_eq!(table.resolve_plural(ITEMS, 0), "zero");
407 assert_eq!(table.resolve_plural(ITEMS, 1), "one");
408 assert_eq!(table.resolve_plural(ITEMS, 2), "two");
409 assert_eq!(table.resolve_plural(ITEMS, 5), "few");
410 assert_eq!(table.resolve_plural(ITEMS, 20), "many");
411 assert_eq!(table.resolve_plural(ITEMS, 100), "other");
412 }
413
414 #[test]
415 fn missing_plural_key_falls_back_to_id() {
416 let table = StringTable::new();
417 assert_eq!(table.resolve_plural(ITEMS, 1), "status.items");
418 }
419
420 #[test]
421 fn english_number_formatting_groups_with_comma() {
422 let table = StringTable::for_locale(Locale::EnUs);
423 assert_eq!(table.format_count(0), "0");
424 assert_eq!(table.format_count(42), "42");
425 assert_eq!(table.format_count(1234), "1,234");
426 assert_eq!(table.format_count(1_000_000), "1,000,000");
427 }
428
429 #[test]
430 fn arabic_number_formatting_uses_indic_digits() {
431 let table = StringTable::for_locale(Locale::ArXb);
432 assert_eq!(table.format_count(42), "\u{0664}\u{0662}");
433 assert_eq!(
434 table.format_count(1234),
435 "\u{0661}\u{066C}\u{0662}\u{0663}\u{0664}",
436 );
437 }
438
439 #[test]
440 fn format_plural_substitutes_count_into_template() {
441 let mut table = StringTable::new();
442 table.insert_plural(ITEMS, PluralEntry::new("{n} item", "{n} items"));
443 assert_eq!(table.format_plural(ITEMS, 1), "1 item");
444 assert_eq!(table.format_plural(ITEMS, 1234), "1,234 items");
445 }
446
447 #[test]
448 fn format_plural_routes_count_through_locale_digits() {
449 let mut table = StringTable::for_locale(Locale::ArXb);
450 table.insert_plural(ITEMS, PluralEntry::other("{n}"));
451 assert_eq!(table.format_plural(ITEMS, 42), "\u{0664}\u{0662}");
452 }
453
454 #[test]
455 fn plural_entry_other_only_falls_back_to_other_for_every_category() {
456 let mut table = StringTable::new();
457 table.insert_plural(ITEMS, PluralEntry::other("{n} 件"));
458 assert_eq!(table.resolve_plural(ITEMS, 0), "{n} 件");
459 assert_eq!(table.resolve_plural(ITEMS, 1), "{n} 件");
460 assert_eq!(table.resolve_plural(ITEMS, 5), "{n} 件");
461 }
462}