Another project
0

Configure Feed

Select the types of activity you want to include in your feed.

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}