Skip to main content

eska_presentation/localization/
localizer.rs

1//! Embedded Fluent resources and translation formatting.
2
3use std::{error::Error, fmt};
4
5use fluent::{FluentArgs, FluentBundle, FluentResource, FluentValue};
6
7use super::Locale;
8
9const EN_US_RESOURCES: &[&str] = &[
10    include_str!("../../locales/en-US/main.ftl"),
11    include_str!("../../locales/en-US/platform-8.3.27.ftl"),
12    include_str!("../../locales/en-US/platform-8.5.1.ftl"),
13    include_str!("../../locales/en-US/platform-values.ftl"),
14    include_str!("../../locales/en-US/platform-presentation.ftl"),
15];
16const RU_RU_RESOURCES: &[&str] = &[
17    include_str!("../../locales/ru-RU/main.ftl"),
18    include_str!("../../locales/ru-RU/platform-8.3.27.ftl"),
19    include_str!("../../locales/ru-RU/platform-8.5.1.ftl"),
20    include_str!("../../locales/ru-RU/platform-values.ftl"),
21    include_str!("../../locales/ru-RU/platform-presentation.ftl"),
22];
23
24/// A value supplied to a parameterized translation.
25#[derive(Clone, Copy, Debug)]
26pub enum LocalizationValue<'a> {
27    Text(&'a str),
28    Number(i64),
29}
30
31/// Immutable access to eska's embedded translations.
32pub struct Localizer {
33    locale: Locale,
34    bundle: FluentBundle<FluentResource>,
35}
36
37impl Localizer {
38    /// Creates a localizer from the translation resource embedded in the binary.
39    ///
40    /// # Errors
41    ///
42    /// This can fail only if an embedded resource or its canonical locale identifier
43    /// is invalid. Tests validate both resources so installation needs no external files.
44    pub fn try_new(locale: Locale) -> Result<Self, LocalizationError> {
45        let sources = match locale {
46            Locale::RuRu => RU_RU_RESOURCES,
47            Locale::EnUs => EN_US_RESOURCES,
48        };
49        let language_id = locale.as_str().parse().map_err(|error| {
50            LocalizationError::new(format!(
51                "invalid locale identifier {}: {error}",
52                locale.as_str()
53            ))
54        })?;
55        let mut bundle = FluentBundle::new(vec![language_id]);
56        for source in sources {
57            let resource =
58                FluentResource::try_new((*source).to_owned()).map_err(|(_, errors)| {
59                    LocalizationError::new(format!(
60                        "invalid embedded {} resource: {errors:?}",
61                        locale.as_str()
62                    ))
63                })?;
64            bundle.add_resource(resource).map_err(|errors| {
65                LocalizationError::new(format!(
66                    "invalid embedded {} bundle: {errors:?}",
67                    locale.as_str()
68                ))
69            })?;
70        }
71
72        Ok(Self { locale, bundle })
73    }
74
75    /// Returns the locale used by this localizer.
76    #[must_use]
77    pub const fn locale(&self) -> Locale {
78        self.locale
79    }
80
81    /// Resolves a translation without parameters.
82    #[must_use]
83    pub fn text(&self, key: &str) -> String {
84        self.format(key, &[])
85    }
86
87    /// Resolve an open-ended catalog key without treating future platform fields as errors.
88    pub(super) fn optional_text(&self, key: &str) -> Option<String> {
89        self.bundle.get_message(key)?.value()?;
90        Some(self.text(key))
91    }
92
93    /// Resolves a translation with named parameters.
94    #[must_use]
95    pub fn format(&self, key: &str, values: &[(&str, LocalizationValue<'_>)]) -> String {
96        let Some(message) = self.bundle.get_message(key) else {
97            debug_assert!(false, "missing localization key: {key}");
98            return key.to_owned();
99        };
100        let Some(pattern) = message.value() else {
101            debug_assert!(false, "localization key has no value: {key}");
102            return key.to_owned();
103        };
104
105        let mut args = FluentArgs::new();
106        for (name, value) in values {
107            let value = match value {
108                LocalizationValue::Text(value) => FluentValue::from(*value),
109                LocalizationValue::Number(value) => FluentValue::from(*value),
110            };
111            args.set(*name, value);
112        }
113
114        let mut errors = Vec::new();
115        let value = self
116            .bundle
117            .format_pattern(pattern, Some(&args), &mut errors);
118        debug_assert!(
119            errors.is_empty(),
120            "failed to format localization key {key}: {errors:?}"
121        );
122        value.into_owned()
123    }
124}
125
126/// An error in eska's embedded localization data.
127#[derive(Debug)]
128pub struct LocalizationError {
129    message: String,
130}
131
132impl LocalizationError {
133    const fn new(message: String) -> Self {
134        Self { message }
135    }
136}
137
138impl fmt::Display for LocalizationError {
139    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
140        formatter.write_str(&self.message)
141    }
142}
143
144impl Error for LocalizationError {}
145
146#[cfg(test)]
147mod tests {
148    use super::*;
149
150    #[test]
151    fn embedded_resources_have_matching_keys_and_are_valid() {
152        fn keys(resource: &str) -> Vec<&str> {
153            let mut keys: Vec<_> = resource
154                .lines()
155                .filter_map(|line| line.split_once(" = ").map(|(key, _)| key))
156                .collect();
157            keys.sort_unstable();
158            keys
159        }
160
161        for (en, ru) in EN_US_RESOURCES.iter().zip(RU_RU_RESOURCES) {
162            assert_eq!(keys(en), keys(ru));
163        }
164        assert!(Localizer::try_new(Locale::EnUs).is_ok());
165        assert!(Localizer::try_new(Locale::RuRu).is_ok());
166    }
167}