1use std::collections::{BTreeMap, BTreeSet};
2
3use rhai::{Dynamic, Engine, Scope};
4use serde::{Deserialize, Serialize};
5use thiserror::Error;
6
7use crate::{ComponentInstancePath, DateError, GregorianDate, Weekday};
8
9const REQUIRED_MESSAGES: &[&str] = &[
10 "calendar.clear",
11 "calendar.next_month",
12 "calendar.open",
13 "calendar.previous_month",
14 "common.clear",
15 "common.close",
16 "common.loading",
17 "common.no_results",
18 "pagination.items",
19 "pagination.next",
20 "pagination.page",
21 "pagination.per_page",
22 "pagination.previous",
23];
24
25#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
26#[serde(rename_all = "snake_case")]
27pub enum TextDirection {
28 LeftToRight,
29 RightToLeft,
30}
31
32#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
33pub struct CalendarNames {
34 pub short: Vec<String>,
35 pub long: Vec<String>,
36}
37
38#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
39pub struct DatePatterns {
40 pub month_year: String,
41 pub short: String,
42 pub medium: String,
43 pub long: String,
44}
45
46#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
47pub struct CalendarMetadata {
48 pub first_weekday: Weekday,
49 pub months: CalendarNames,
50 pub weekdays: CalendarNames,
51 pub date_patterns: DatePatterns,
52}
53
54impl CalendarMetadata {
55 fn validate(&self) -> Result<(), LocaleError> {
56 validate_names("calendar.months.short", &self.months.short, 12)?;
57 validate_names("calendar.months.long", &self.months.long, 12)?;
58 validate_names("calendar.weekdays.short", &self.weekdays.short, 7)?;
59 validate_names("calendar.weekdays.long", &self.weekdays.long, 7)?;
60 for (name, pattern) in [
61 ("month_year", &self.date_patterns.month_year),
62 ("short", &self.date_patterns.short),
63 ("medium", &self.date_patterns.medium),
64 ("long", &self.date_patterns.long),
65 ] {
66 validate_date_pattern(pattern).map_err(|message| LocaleError::InvalidCalendar {
67 field: format!("calendar.date_patterns.{name}"),
68 message,
69 })?;
70 }
71 let month_year = &self.date_patterns.month_year;
72 if !month_year.contains("yyyy")
73 || !month_year.contains('M')
74 || month_year.contains('d')
75 || month_year.contains('E')
76 {
77 return Err(LocaleError::InvalidCalendar {
78 field: "calendar.date_patterns.month_year".to_owned(),
79 message: "month_year must contain year and month tokens only".to_owned(),
80 });
81 }
82 Ok(())
83 }
84}
85
86#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
87pub struct NumberMetadata {
88 pub digits: Vec<String>,
89 pub decimal_separator: String,
90 pub grouping_separator: String,
91 pub primary_group_size: u8,
92 pub secondary_group_size: u8,
93 pub minus_sign: String,
94}
95
96impl NumberMetadata {
97 fn validate(&self) -> Result<(), LocaleError> {
98 validate_names("number.digits", &self.digits, 10).map_err(|error| match error {
99 LocaleError::InvalidCalendar { field, message } => {
100 LocaleError::InvalidNumber { field, message }
101 }
102 other => other,
103 })?;
104 for (field, value) in [
105 ("number.decimal_separator", &self.decimal_separator),
106 ("number.grouping_separator", &self.grouping_separator),
107 ("number.minus_sign", &self.minus_sign),
108 ] {
109 if value.is_empty() {
110 return Err(LocaleError::InvalidNumber {
111 field: field.to_owned(),
112 message: "value cannot be empty".to_owned(),
113 });
114 }
115 }
116 if self.decimal_separator == self.grouping_separator {
117 return Err(LocaleError::InvalidNumber {
118 field: "number.grouping_separator".to_owned(),
119 message: "grouping and decimal separators must differ".to_owned(),
120 });
121 }
122 for (field, size) in [
123 ("number.primary_group_size", self.primary_group_size),
124 ("number.secondary_group_size", self.secondary_group_size),
125 ] {
126 if !(1..=9).contains(&size) {
127 return Err(LocaleError::InvalidNumber {
128 field: field.to_owned(),
129 message: "group size must be between 1 and 9".to_owned(),
130 });
131 }
132 }
133 Ok(())
134 }
135}
136
137#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
138pub struct LocaleBundle {
139 pub locale: String,
140 pub direction: TextDirection,
141 pub messages: BTreeMap<String, String>,
142 pub calendar: CalendarMetadata,
143 pub number: NumberMetadata,
144}
145
146impl LocaleBundle {
147 pub fn validate(&self) -> Result<(), LocaleError> {
154 if self.locale.trim().is_empty() {
155 return Err(LocaleError::EmptyLocale);
156 }
157 let missing = REQUIRED_MESSAGES
158 .iter()
159 .filter(|key| !self.messages.contains_key(**key))
160 .map(ToString::to_string)
161 .collect::<Vec<_>>();
162 if !missing.is_empty() {
163 return Err(LocaleError::MissingMessages {
164 locale: self.locale.clone(),
165 missing,
166 });
167 }
168 if let Some((key, _)) = self
169 .messages
170 .iter()
171 .find(|(key, value)| key.trim().is_empty() || value.is_empty())
172 {
173 return Err(LocaleError::InvalidMessage(key.clone()));
174 }
175 self.calendar.validate()?;
176 self.number.validate()
177 }
178}
179
180#[derive(Clone, Copy, Debug, Eq, PartialEq)]
181pub enum DateStyle {
182 Short,
183 Medium,
184 Long,
185}
186
187impl DateStyle {
188 pub fn parse(value: &str) -> Result<Self, LocaleError> {
194 match value {
195 "short" => Ok(Self::Short),
196 "medium" => Ok(Self::Medium),
197 "long" => Ok(Self::Long),
198 _ => Err(LocaleError::UnknownDateStyle(value.to_owned())),
199 }
200 }
201}
202
203#[derive(Clone, Copy, Debug, Eq, PartialEq)]
204pub struct NumberFormatOptions {
205 pub min_fraction_digits: u8,
206 pub max_fraction_digits: u8,
207 pub grouping: bool,
208}
209
210impl NumberFormatOptions {
211 pub fn validate(self) -> Result<(), LocaleError> {
219 if self.max_fraction_digits > 12 || self.min_fraction_digits > self.max_fraction_digits {
220 Err(LocaleError::InvalidNumberOptions)
221 } else {
222 Ok(())
223 }
224 }
225}
226
227impl Default for NumberFormatOptions {
228 fn default() -> Self {
229 Self {
230 min_fraction_digits: 0,
231 max_fraction_digits: 3,
232 grouping: true,
233 }
234 }
235}
236
237#[derive(Clone, Debug)]
238pub struct LocaleManager {
239 bundles: BTreeMap<String, LocaleBundle>,
240 fallback: String,
241 app: String,
242 windows: BTreeMap<String, String>,
243 scopes: BTreeMap<ComponentInstancePath, String>,
244 generation: u64,
245}
246
247impl LocaleManager {
248 pub fn new(
254 bundles: impl IntoIterator<Item = LocaleBundle>,
255 fallback: impl Into<String>,
256 app: impl Into<String>,
257 ) -> Result<Self, LocaleError> {
258 let mut map = BTreeMap::new();
259 for bundle in bundles {
260 bundle.validate()?;
261 if map.insert(bundle.locale.clone(), bundle).is_some() {
262 return Err(LocaleError::DuplicateLocale);
263 }
264 }
265 let manager = Self {
266 bundles: map,
267 fallback: fallback.into(),
268 app: app.into(),
269 windows: BTreeMap::new(),
270 scopes: BTreeMap::new(),
271 generation: 1,
272 };
273 manager.require_locale(&manager.fallback)?;
274 manager.require_locale(&manager.app)?;
275 Ok(manager)
276 }
277
278 pub fn set_app(&mut self, locale: impl Into<String>) -> Result<(), LocaleError> {
284 let locale = locale.into();
285 self.require_locale(&locale)?;
286 if self.app != locale {
287 self.app = locale;
288 self.generation = self.generation.saturating_add(1);
289 }
290 Ok(())
291 }
292
293 pub fn set_window(
299 &mut self,
300 window: impl Into<String>,
301 locale: impl Into<String>,
302 ) -> Result<(), LocaleError> {
303 let locale = locale.into();
304 self.require_locale(&locale)?;
305 let window = window.into();
306 if self.windows.get(&window) != Some(&locale) {
307 self.windows.insert(window, locale);
308 self.generation = self.generation.saturating_add(1);
309 }
310 Ok(())
311 }
312
313 pub fn remove_window(&mut self, window: &str) -> bool {
314 let removed = self.windows.remove(window).is_some();
315 if removed {
316 self.generation = self.generation.saturating_add(1);
317 }
318 removed
319 }
320
321 pub fn remove_scope(&mut self, scope: &ComponentInstancePath) -> bool {
322 let previous = self.scopes.len();
323 self.scopes.retain(|path, _| !path.is_within(scope));
324 let removed = self.scopes.len() != previous;
325 if removed {
326 self.generation = self.generation.saturating_add(1);
327 }
328 removed
329 }
330
331 pub fn set_scope(
337 &mut self,
338 scope: ComponentInstancePath,
339 locale: impl Into<String>,
340 ) -> Result<(), LocaleError> {
341 let locale = locale.into();
342 self.require_locale(&locale)?;
343 if self.scopes.get(&scope) != Some(&locale) {
344 self.scopes.insert(scope, locale);
345 self.generation = self.generation.saturating_add(1);
346 }
347 Ok(())
348 }
349
350 #[must_use]
351 pub const fn generation(&self) -> u64 {
352 self.generation
353 }
354
355 #[must_use]
356 pub fn app_locale(&self) -> &str {
357 &self.app
358 }
359
360 pub fn locale(
366 &self,
367 window: Option<&str>,
368 component: Option<&ComponentInstancePath>,
369 ) -> Result<&str, LocaleError> {
370 Ok(self.selected_bundle(window, component)?.locale.as_str())
371 }
372
373 pub fn text(
380 &self,
381 window: Option<&str>,
382 component: Option<&ComponentInstancePath>,
383 key: &str,
384 ) -> Result<String, LocaleError> {
385 let locale = self.selected_locale(window, component);
386 self.bundles
387 .get(locale)
388 .and_then(|bundle| bundle.messages.get(key))
389 .or_else(|| {
390 self.bundles
391 .get(&self.fallback)
392 .and_then(|bundle| bundle.messages.get(key))
393 })
394 .cloned()
395 .ok_or_else(|| LocaleError::UnknownMessage(key.to_owned()))
396 }
397
398 pub fn direction(
404 &self,
405 window: Option<&str>,
406 component: Option<&ComponentInstancePath>,
407 ) -> Result<TextDirection, LocaleError> {
408 Ok(self.selected_bundle(window, component)?.direction)
409 }
410
411 pub fn calendar(
417 &self,
418 window: Option<&str>,
419 component: Option<&ComponentInstancePath>,
420 ) -> Result<&CalendarMetadata, LocaleError> {
421 Ok(&self.selected_bundle(window, component)?.calendar)
422 }
423
424 pub fn number(
430 &self,
431 window: Option<&str>,
432 component: Option<&ComponentInstancePath>,
433 ) -> Result<&NumberMetadata, LocaleError> {
434 Ok(&self.selected_bundle(window, component)?.number)
435 }
436
437 pub fn format_date(
443 &self,
444 window: Option<&str>,
445 component: Option<&ComponentInstancePath>,
446 iso_date: &str,
447 style: DateStyle,
448 ) -> Result<String, LocaleError> {
449 let bundle = self.selected_bundle(window, component)?;
450 format_date_with_metadata(iso_date, style, &bundle.calendar, &bundle.number)
451 }
452
453 pub fn format_month_year(
459 &self,
460 window: Option<&str>,
461 component: Option<&ComponentInstancePath>,
462 iso_date: &str,
463 ) -> Result<String, LocaleError> {
464 let bundle = self.selected_bundle(window, component)?;
465 let date = GregorianDate::parse_iso(iso_date)?;
466 format_month_year_with_metadata(date, &bundle.calendar, &bundle.number)
467 }
468
469 pub fn format_integer(
475 &self,
476 window: Option<&str>,
477 component: Option<&ComponentInstancePath>,
478 value: i64,
479 options: NumberFormatOptions,
480 ) -> Result<String, LocaleError> {
481 let bundle = self.selected_bundle(window, component)?;
482 format_integer_with_metadata(value, options, &bundle.number)
483 }
484
485 pub fn format_number(
491 &self,
492 window: Option<&str>,
493 component: Option<&ComponentInstancePath>,
494 value: f64,
495 options: NumberFormatOptions,
496 ) -> Result<String, LocaleError> {
497 let bundle = self.selected_bundle(window, component)?;
498 format_number_with_metadata(value, options, &bundle.number)
499 }
500
501 fn selected_bundle(
502 &self,
503 window: Option<&str>,
504 component: Option<&ComponentInstancePath>,
505 ) -> Result<&LocaleBundle, LocaleError> {
506 let locale = self.selected_locale(window, component);
507 self.bundles
508 .get(locale)
509 .ok_or_else(|| LocaleError::UnknownLocale(locale.to_owned()))
510 }
511
512 fn selected_locale<'a>(
513 &'a self,
514 window: Option<&str>,
515 component: Option<&ComponentInstancePath>,
516 ) -> &'a str {
517 component
518 .and_then(|component| self.nearest_scope(component))
519 .or_else(|| window.and_then(|window| self.windows.get(window)))
520 .map_or(self.app.as_str(), String::as_str)
521 }
522
523 fn nearest_scope(&self, component: &ComponentInstancePath) -> Option<&String> {
524 let mut current = Some(component.clone());
525 while let Some(path) = current {
526 if let Some(locale) = self.scopes.get(&path) {
527 return Some(locale);
528 }
529 current = path.parent();
530 }
531 None
532 }
533
534 fn require_locale(&self, locale: &str) -> Result<(), LocaleError> {
535 if self.bundles.contains_key(locale) {
536 Ok(())
537 } else {
538 Err(LocaleError::UnknownLocale(locale.to_owned()))
539 }
540 }
541}
542
543fn validate_names(field: &str, values: &[String], expected: usize) -> Result<(), LocaleError> {
544 if values.len() != expected {
545 return Err(LocaleError::InvalidCalendar {
546 field: field.to_owned(),
547 message: format!("expected {expected} entries, got {}", values.len()),
548 });
549 }
550 let mut unique = BTreeSet::new();
551 if let Some(value) = values
552 .iter()
553 .find(|value| value.is_empty() || !unique.insert(value.as_str()))
554 {
555 return Err(LocaleError::InvalidCalendar {
556 field: field.to_owned(),
557 message: if value.is_empty() {
558 "entries cannot be empty".to_owned()
559 } else {
560 format!("entry `{value}` is duplicated")
561 },
562 });
563 }
564 Ok(())
565}
566
567const DATE_TOKENS: &[&str] = &["MMMM", "yyyy", "EEEE", "MMM", "EEE", "MM", "dd", "M", "d"];
568
569fn validate_date_pattern(pattern: &str) -> Result<(), String> {
570 if pattern.is_empty() {
571 return Err("pattern cannot be empty".to_owned());
572 }
573 let mut remaining = pattern;
574 while !remaining.is_empty() {
575 if let Some(token) = DATE_TOKENS
576 .iter()
577 .find(|token| remaining.starts_with(**token))
578 {
579 remaining = &remaining[token.len()..];
580 continue;
581 }
582 let character = remaining.chars().next().expect("remaining is non-empty");
583 if character.is_ascii_alphabetic() {
584 return Err(format!(
585 "unsupported pattern token beginning with `{character}`"
586 ));
587 }
588 remaining = &remaining[character.len_utf8()..];
589 }
590 Ok(())
591}
592
593fn render_date_pattern(
594 date: GregorianDate,
595 pattern: &str,
596 calendar: &CalendarMetadata,
597 number: &NumberMetadata,
598) -> Result<String, LocaleError> {
599 let mut output = String::new();
600 let mut remaining = pattern;
601 while !remaining.is_empty() {
602 let Some(token) = DATE_TOKENS
603 .iter()
604 .find(|token| remaining.starts_with(**token))
605 .copied()
606 else {
607 let character = remaining.chars().next().expect("remaining is non-empty");
608 output.push(character);
609 remaining = &remaining[character.len_utf8()..];
610 continue;
611 };
612 let ascii = match token {
613 "yyyy" => format!("{:04}", date.year()),
614 "MM" => format!("{:02}", date.month()),
615 "M" => date.month().to_string(),
616 "dd" => format!("{:02}", date.day()),
617 "d" => date.day().to_string(),
618 "MMM" => calendar.months.short[usize::from(date.month() - 1)].clone(),
619 "MMMM" => calendar.months.long[usize::from(date.month() - 1)].clone(),
620 "EEE" => calendar.weekdays.short[date.weekday().sunday_index()].clone(),
621 "EEEE" => calendar.weekdays.long[date.weekday().sunday_index()].clone(),
622 _ => return Err(LocaleError::InvalidDatePattern(pattern.to_owned())),
623 };
624 if matches!(token, "yyyy" | "MM" | "M" | "dd" | "d") {
625 output.push_str(&localize_ascii_digits(&ascii, &number.digits));
626 } else {
627 output.push_str(&ascii);
628 }
629 remaining = &remaining[token.len()..];
630 }
631 Ok(output)
632}
633
634fn render_number_parts(
635 negative: bool,
636 integer: &str,
637 fraction: &str,
638 number: &NumberMetadata,
639 options: NumberFormatOptions,
640) -> String {
641 let integer = if options.grouping {
642 group_ascii_digits(
643 integer,
644 usize::from(number.primary_group_size),
645 usize::from(number.secondary_group_size),
646 &number.grouping_separator,
647 )
648 } else {
649 integer.to_owned()
650 };
651 let mut output = String::new();
652 if negative {
653 output.push_str(&number.minus_sign);
654 }
655 output.push_str(&localize_ascii_digits(&integer, &number.digits));
656 if !fraction.is_empty() {
657 output.push_str(&number.decimal_separator);
658 output.push_str(&localize_ascii_digits(fraction, &number.digits));
659 }
660 output
661}
662
663fn group_ascii_digits(integer: &str, primary: usize, secondary: usize, separator: &str) -> String {
664 if integer.len() <= primary {
665 return integer.to_owned();
666 }
667 let mut groups = Vec::new();
668 let mut end = integer.len();
669 let mut size = primary;
670 while end > 0 {
671 let start = end.saturating_sub(size);
672 groups.push(&integer[start..end]);
673 end = start;
674 size = secondary;
675 }
676 groups.reverse();
677 groups.join(separator)
678}
679
680fn localize_ascii_digits(value: &str, digits: &[String]) -> String {
681 let mut output = String::new();
682 for character in value.chars() {
683 if let Some(index) = character.to_digit(10) {
684 output.push_str(&digits[usize::try_from(index).expect("decimal digit fits usize")]);
685 } else {
686 output.push(character);
687 }
688 }
689 output
690}
691
692pub fn format_date_with_metadata(
698 iso_date: &str,
699 style: DateStyle,
700 calendar: &CalendarMetadata,
701 number: &NumberMetadata,
702) -> Result<String, LocaleError> {
703 let date = GregorianDate::parse_iso(iso_date)?;
704 let pattern = match style {
705 DateStyle::Short => &calendar.date_patterns.short,
706 DateStyle::Medium => &calendar.date_patterns.medium,
707 DateStyle::Long => &calendar.date_patterns.long,
708 };
709 render_date_pattern(date, pattern, calendar, number)
710}
711
712pub(crate) fn format_month_year_with_metadata(
713 date: GregorianDate,
714 calendar: &CalendarMetadata,
715 number: &NumberMetadata,
716) -> Result<String, LocaleError> {
717 render_date_pattern(date, &calendar.date_patterns.month_year, calendar, number)
718}
719
720pub fn format_integer_with_metadata(
726 value: i64,
727 options: NumberFormatOptions,
728 number: &NumberMetadata,
729) -> Result<String, LocaleError> {
730 options.validate()?;
731 Ok(render_number_parts(
732 value.is_negative(),
733 &value.unsigned_abs().to_string(),
734 "",
735 number,
736 options,
737 ))
738}
739
740pub fn format_number_with_metadata(
746 value: f64,
747 options: NumberFormatOptions,
748 number: &NumberMetadata,
749) -> Result<String, LocaleError> {
750 options.validate()?;
751 if !value.is_finite() {
752 return Err(LocaleError::NonFiniteNumber);
753 }
754 let rendered = format!(
755 "{:.*}",
756 usize::from(options.max_fraction_digits),
757 value.abs()
758 );
759 let (integer, mut fraction) = rendered
760 .split_once('.')
761 .map_or((rendered.as_str(), String::new()), |(integer, fraction)| {
762 (integer, fraction.to_owned())
763 });
764 while fraction.len() > usize::from(options.min_fraction_digits) && fraction.ends_with('0') {
765 fraction.pop();
766 }
767 Ok(render_number_parts(
768 value.is_sign_negative() && value != 0.0,
769 integer,
770 &fraction,
771 number,
772 options,
773 ))
774}
775
776pub fn load_locale_source(
782 engine: &Engine,
783 source_name: &str,
784 source: &str,
785) -> Result<LocaleBundle, LocaleError> {
786 let mut ast = engine
787 .compile(source)
788 .map_err(|error| LocaleError::Script(error.to_string()))?;
789 crate::engine::validate_assignment_targets(&ast)
790 .map_err(|error| LocaleError::Script(error.to_string()))?;
791 ast.set_source(source_name);
792 let raw: Dynamic = engine
793 .call_fn(&mut Scope::new(), &ast, "locale", ())
794 .map_err(|error| LocaleError::Script(error.to_string()))?;
795 let bundle = rhai::serde::from_dynamic::<LocaleBundle>(&raw)
796 .map_err(|error| LocaleError::Decode(error.to_string()))?;
797 bundle.validate()?;
798 Ok(bundle)
799}
800
801#[derive(Clone, Debug, Error, Eq, PartialEq)]
802pub enum LocaleError {
803 #[error("locale ID cannot be empty")]
804 EmptyLocale,
805 #[error("locale bundle is duplicated")]
806 DuplicateLocale,
807 #[error("locale `{locale}` is missing messages: {missing:?}")]
808 MissingMessages {
809 locale: String,
810 missing: Vec<String>,
811 },
812 #[error("locale message `{0}` has an empty key or value")]
813 InvalidMessage(String),
814 #[error("invalid {field}: {message}")]
815 InvalidCalendar { field: String, message: String },
816 #[error("invalid {field}: {message}")]
817 InvalidNumber { field: String, message: String },
818 #[error("locale `{0}` is not loaded")]
819 UnknownLocale(String),
820 #[error("locale message `{0}` is not defined")]
821 UnknownMessage(String),
822 #[error("date style `{0}` is unknown")]
823 UnknownDateStyle(String),
824 #[error("date pattern `{0}` could not be rendered")]
825 InvalidDatePattern(String),
826 #[error("number format options require 0 <= min_fraction_digits <= max_fraction_digits <= 12")]
827 InvalidNumberOptions,
828 #[error("cannot format a non-finite number")]
829 NonFiniteNumber,
830 #[error(transparent)]
831 Date(#[from] DateError),
832 #[error("locale script failed: {0}")]
833 Script(String),
834 #[error("locale source could not be decoded: {0}")]
835 Decode(String),
836}
837
838#[cfg(test)]
839mod tests {
840 use super::*;
841
842 fn calendar() -> CalendarMetadata {
843 CalendarMetadata {
844 first_weekday: Weekday::Sunday,
845 months: CalendarNames {
846 short: [
847 "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov",
848 "Dec",
849 ]
850 .map(str::to_owned)
851 .to_vec(),
852 long: [
853 "January",
854 "February",
855 "March",
856 "April",
857 "May",
858 "June",
859 "July",
860 "August",
861 "September",
862 "October",
863 "November",
864 "December",
865 ]
866 .map(str::to_owned)
867 .to_vec(),
868 },
869 weekdays: CalendarNames {
870 short: ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]
871 .map(str::to_owned)
872 .to_vec(),
873 long: [
874 "Sunday",
875 "Monday",
876 "Tuesday",
877 "Wednesday",
878 "Thursday",
879 "Friday",
880 "Saturday",
881 ]
882 .map(str::to_owned)
883 .to_vec(),
884 },
885 date_patterns: DatePatterns {
886 month_year: "MMMM yyyy".to_owned(),
887 short: "MM/dd/yyyy".to_owned(),
888 medium: "MMM d, yyyy".to_owned(),
889 long: "EEEE, MMMM d, yyyy".to_owned(),
890 },
891 }
892 }
893
894 fn number() -> NumberMetadata {
895 NumberMetadata {
896 digits: ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"]
897 .map(str::to_owned)
898 .to_vec(),
899 decimal_separator: ".".to_owned(),
900 grouping_separator: ",".to_owned(),
901 primary_group_size: 3,
902 secondary_group_size: 3,
903 minus_sign: "-".to_owned(),
904 }
905 }
906
907 fn bundle(locale: &str, loading: &str, direction: TextDirection) -> LocaleBundle {
908 LocaleBundle {
909 locale: locale.to_owned(),
910 direction,
911 messages: BTreeMap::from([
912 ("calendar.clear".to_owned(), "Clear date".to_owned()),
913 ("calendar.next_month".to_owned(), "Next month".to_owned()),
914 ("calendar.open".to_owned(), "Open calendar".to_owned()),
915 (
916 "calendar.previous_month".to_owned(),
917 "Previous month".to_owned(),
918 ),
919 ("common.clear".to_owned(), "Clear".to_owned()),
920 ("common.close".to_owned(), "Close".to_owned()),
921 ("common.loading".to_owned(), loading.to_owned()),
922 ("common.no_results".to_owned(), "No results".to_owned()),
923 ("pagination.items".to_owned(), "items".to_owned()),
924 ("pagination.next".to_owned(), "Next page".to_owned()),
925 ("pagination.page".to_owned(), "Page".to_owned()),
926 (
927 "pagination.per_page".to_owned(),
928 "Items per page".to_owned(),
929 ),
930 ("pagination.previous".to_owned(), "Previous page".to_owned()),
931 ]),
932 calendar: calendar(),
933 number: number(),
934 }
935 }
936
937 #[test]
938 fn scope_precedence_and_fallback_are_deterministic() {
939 let mut locales = LocaleManager::new(
940 [
941 bundle("en", "Loading", TextDirection::LeftToRight),
942 bundle("zh-CN", "加载中", TextDirection::LeftToRight),
943 ],
944 "en",
945 "en",
946 )
947 .unwrap();
948 locales.set_window("main", "zh-CN").unwrap();
949 let root = ComponentInstancePath::root("App", "root");
950 locales.set_scope(root.clone(), "en").unwrap();
951 assert_eq!(
952 locales
953 .text(
954 Some("main"),
955 Some(&root.child("Button", "save")),
956 "common.loading",
957 )
958 .unwrap(),
959 "Loading"
960 );
961 }
962
963 #[test]
964 fn live_locale_switch_advances_only_locale_generation() {
965 let mut locales = LocaleManager::new(
966 [
967 bundle("en", "Loading", TextDirection::LeftToRight),
968 bundle("zh-CN", "加载中", TextDirection::LeftToRight),
969 ],
970 "en",
971 "en",
972 )
973 .unwrap();
974 let generation = locales.generation();
975 locales.set_app("zh-CN").unwrap();
976 assert_eq!(locales.generation(), generation + 1);
977 let generation = locales.generation();
978 locales.set_app("zh-CN").unwrap();
979 locales.set_window("main", "zh-CN").unwrap();
980 let window_generation = locales.generation();
981 locales.set_window("main", "zh-CN").unwrap();
982 assert_eq!(locales.generation(), window_generation);
983 assert_eq!(generation + 1, window_generation);
984 }
985
986 #[test]
987 fn rtl_direction_follows_scope_window_and_app_precedence() {
988 let mut locales = LocaleManager::new(
989 [
990 bundle("en", "Loading", TextDirection::LeftToRight),
991 bundle("ar", "جارٍ التحميل", TextDirection::RightToLeft),
992 ],
993 "en",
994 "ar",
995 )
996 .unwrap();
997 let root = ComponentInstancePath::root("App", "root");
998 assert_eq!(
999 locales.direction(Some("main"), Some(&root)).unwrap(),
1000 TextDirection::RightToLeft
1001 );
1002 locales.set_window("main", "en").unwrap();
1003 assert_eq!(
1004 locales.direction(Some("main"), Some(&root)).unwrap(),
1005 TextDirection::LeftToRight
1006 );
1007 locales.set_scope(root.clone(), "ar").unwrap();
1008 assert_eq!(
1009 locales
1010 .direction(Some("main"), Some(&root.child("Button", "save")))
1011 .unwrap(),
1012 TextDirection::RightToLeft
1013 );
1014 }
1015
1016 #[test]
1017 fn dates_and_numbers_use_selected_bundle_metadata() {
1018 let locales = LocaleManager::new(
1019 [bundle("en", "Loading", TextDirection::LeftToRight)],
1020 "en",
1021 "en",
1022 )
1023 .unwrap();
1024 assert_eq!(
1025 locales
1026 .format_date(None, None, "2024-02-29", DateStyle::Long)
1027 .unwrap(),
1028 "Thursday, February 29, 2024"
1029 );
1030 assert_eq!(
1031 format_month_year_with_metadata(
1032 GregorianDate::parse_iso("2024-02-29").unwrap(),
1033 &calendar(),
1034 &number(),
1035 )
1036 .unwrap(),
1037 "February 2024"
1038 );
1039 assert_eq!(
1040 locales
1041 .format_number(
1042 None,
1043 None,
1044 -12345.6,
1045 NumberFormatOptions {
1046 min_fraction_digits: 2,
1047 max_fraction_digits: 2,
1048 grouping: true,
1049 },
1050 )
1051 .unwrap(),
1052 "-12,345.60"
1053 );
1054 assert_eq!(
1055 locales
1056 .format_integer(None, None, i64::MIN, NumberFormatOptions::default())
1057 .unwrap(),
1058 "-9,223,372,036,854,775,808"
1059 );
1060 }
1061
1062 #[test]
1063 fn month_year_pattern_controls_date_picker_header_order() {
1064 let engine = crate::RuntimeEngine::new();
1065 let zh = load_locale_source(
1066 engine.engine(),
1067 "zh_cn.rhai",
1068 include_str!("../../../registry/locales/zh_cn.rhai"),
1069 )
1070 .unwrap();
1071 assert_eq!(
1072 format_month_year_with_metadata(
1073 GregorianDate::parse_iso("2024-02-29").unwrap(),
1074 &zh.calendar,
1075 &zh.number,
1076 )
1077 .unwrap(),
1078 "2024年二月"
1079 );
1080 }
1081
1082 #[test]
1083 fn malformed_calendar_and_number_contracts_fail_load() {
1084 let mut invalid = bundle("en", "Loading", TextDirection::LeftToRight);
1085 invalid.calendar.months.short.pop();
1086 assert!(matches!(
1087 invalid.validate(),
1088 Err(LocaleError::InvalidCalendar { .. })
1089 ));
1090 let mut invalid = bundle("en", "Loading", TextDirection::LeftToRight);
1091 invalid.calendar.date_patterns.month_year = "MMMM d, yyyy".to_owned();
1092 assert!(matches!(
1093 invalid.validate(),
1094 Err(LocaleError::InvalidCalendar { .. })
1095 ));
1096 let mut invalid = bundle("en", "Loading", TextDirection::LeftToRight);
1097 invalid.number.decimal_separator = ",".to_owned();
1098 assert!(matches!(
1099 invalid.validate(),
1100 Err(LocaleError::InvalidNumber { .. })
1101 ));
1102 }
1103}