Skip to main content

gpui_rhai/
locale.rs

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    /// Validate locale identity, internal messages, calendar data, and number
148    /// presentation metadata.
149    ///
150    /// # Errors
151    ///
152    /// Returns [`LocaleError`] for an incomplete or malformed bundle.
153    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    /// Parse the stable public date presentation vocabulary.
189    ///
190    /// # Errors
191    ///
192    /// Returns [`LocaleError::UnknownDateStyle`] for another value.
193    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    /// Validate fraction digit ordering and the deliberately bounded formatter
212    /// surface.
213    ///
214    /// # Errors
215    ///
216    /// Returns [`LocaleError::InvalidNumberOptions`] above twelve digits or for
217    /// an inverted min/max pair.
218    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    /// Create a locale manager from validated bundles.
249    ///
250    /// # Errors
251    ///
252    /// Returns [`LocaleError`] for invalid, duplicate, or unknown defaults.
253    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    /// Change the app locale without touching component state.
279    ///
280    /// # Errors
281    ///
282    /// Returns [`LocaleError::UnknownLocale`] for absent bundles.
283    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    /// Set a window locale override.
294    ///
295    /// # Errors
296    ///
297    /// Returns [`LocaleError::UnknownLocale`] for absent bundles.
298    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    /// Set a component-subtree locale override.
332    ///
333    /// # Errors
334    ///
335    /// Returns [`LocaleError::UnknownLocale`] for absent bundles.
336    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    /// Resolve the selected locale identifier at the nearest scope.
361    ///
362    /// # Errors
363    ///
364    /// Returns if internally stored selection data no longer names a bundle.
365    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    /// Resolve a message using nearest scope and fallback bundle.
374    ///
375    /// # Errors
376    ///
377    /// Returns [`LocaleError::UnknownMessage`] when neither selected nor
378    /// fallback bundle defines the key.
379    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    /// Resolve selected text direction.
399    ///
400    /// # Errors
401    ///
402    /// Returns [`LocaleError`] if internal selection data is invalid.
403    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    /// Resolve immutable calendar metadata for the selected scope.
412    ///
413    /// # Errors
414    ///
415    /// Returns [`LocaleError`] if internal selection data is invalid.
416    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    /// Resolve immutable number metadata for the selected scope.
425    ///
426    /// # Errors
427    ///
428    /// Returns [`LocaleError`] if internal selection data is invalid.
429    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    /// Format a strict ISO Gregorian date through the selected locale.
438    ///
439    /// # Errors
440    ///
441    /// Returns date, pattern, or locale-selection errors.
442    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    /// Format a strict ISO date with the locale's month/year presentation.
454    ///
455    /// # Errors
456    ///
457    /// Returns date, pattern, or locale-selection errors.
458    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    /// Format an integer without lossy float conversion.
470    ///
471    /// # Errors
472    ///
473    /// Returns invalid options or locale-selection errors.
474    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    /// Format a finite decimal number through the selected locale.
486    ///
487    /// # Errors
488    ///
489    /// Returns invalid options, non-finite input, or locale-selection errors.
490    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
692/// Format one strict ISO date with already validated locale metadata.
693///
694/// # Errors
695///
696/// Returns strict date or pattern errors.
697pub 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
720/// Format one integer with already validated locale metadata.
721///
722/// # Errors
723///
724/// Returns invalid formatter options.
725pub 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
740/// Format one finite decimal with already validated locale metadata.
741///
742/// # Errors
743///
744/// Returns invalid formatter options or non-finite input.
745pub 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
776/// Compile and evaluate `locale() -> map` from Rhai source.
777///
778/// # Errors
779///
780/// Returns script, decode, or semantic validation errors.
781pub 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}