Skip to main content

tessera_i18n/
lib.rs

1//! Fluent catalogs and entry-owned language selection for Tessera.
2//!
3//! Configure catalogs through `tessera-build`, register `res::i18n()` with
4//! `EntryPoint::package`, and resolve messages through `res::I18N`.
5
6use std::{
7    borrow::Cow,
8    cmp::Reverse,
9    collections::HashMap,
10    sync::{Arc, OnceLock},
11};
12
13use fluent_bundle::{FluentArgs, FluentResource, concurrent::FluentBundle};
14use tessera_ui::{EntryRegistry, Plugin, State, StateOwner, TesseraPackage, try_with_plugin};
15
16pub use fluent_bundle::FluentValue;
17pub use fluent_templates::{self, LanguageIdentifier, Loader, langid};
18
19/// Named values supplied to a Fluent message.
20pub type Arguments<'a> = HashMap<Cow<'static, str>, FluentValue<'a>>;
21
22/// Fluent catalogs embedded in the binary and parsed per locale on first lookup.
23///
24/// Lookups try the best matching locales before the fallback locale.
25pub struct EmbeddedLoader {
26    locales: Vec<EmbeddedLocale>,
27    fallback: LanguageIdentifier,
28}
29
30struct EmbeddedLocale {
31    id: LanguageIdentifier,
32    sources: &'static [&'static str],
33    bundle: OnceLock<FluentBundle<FluentResource>>,
34}
35
36impl EmbeddedLoader {
37    /// Creates a loader from Fluent sources grouped by locale identifier.
38    ///
39    /// Panics when an identifier is invalid, or later when a locale's sources
40    /// fail to parse or define a message twice.
41    pub fn new(
42        locales: &'static [(&'static str, &'static [&'static str])],
43        fallback: LanguageIdentifier,
44    ) -> Self {
45        Self {
46            locales: locales
47                .iter()
48                .map(|&(id, sources)| EmbeddedLocale {
49                    id: id.parse().expect("invalid locale identifier"),
50                    sources,
51                    bundle: OnceLock::new(),
52                })
53                .collect(),
54            fallback,
55        }
56    }
57
58    fn negotiate(&self, requested: &LanguageIdentifier) -> Vec<&EmbeddedLocale> {
59        let mut available = self.locales.iter().collect::<Vec<_>>();
60        let mut matches = Vec::new();
61        for available_as_range in [false, true] {
62            available.retain(|locale| {
63                let matched = locale.id.matches(requested, available_as_range, false);
64                if matched {
65                    matches.push(*locale);
66                }
67                !matched
68            });
69        }
70        matches.sort_by_key(|locale| {
71            Reverse(
72                usize::from(locale.id.script.is_some())
73                    + usize::from(locale.id.region.is_some())
74                    + locale.id.variants().len(),
75            )
76        });
77        matches
78    }
79
80    fn format(locale: &EmbeddedLocale, id: &str, args: Option<&Arguments<'_>>) -> Option<String> {
81        let bundle = locale.bundle.get_or_init(|| {
82            let mut bundle = FluentBundle::new_concurrent(vec![locale.id.clone()]);
83            for source in locale.sources {
84                let resource = FluentResource::try_new((*source).to_owned())
85                    .unwrap_or_else(|_| panic!("invalid Fluent source for {}", locale.id));
86                bundle
87                    .add_resource(resource)
88                    .unwrap_or_else(|_| panic!("duplicate Fluent message for {}", locale.id));
89            }
90            bundle
91        });
92        let (message, attribute) = match id.split_once('.') {
93            Some((message, attribute)) => (message, Some(attribute)),
94            None => (id, None),
95        };
96        let message = bundle.get_message(message)?;
97        let pattern = match attribute {
98            Some(attribute) => message
99                .attributes()
100                .find(|candidate| candidate.id() == attribute)?
101                .value(),
102            None => message.value()?,
103        };
104        let args = args.map(|args| {
105            args.iter()
106                .map(|(key, value)| (key.as_ref(), value.clone()))
107                .collect::<FluentArgs<'_>>()
108        });
109        let mut errors = Vec::new();
110        let value = bundle.format_pattern(pattern, args.as_ref(), &mut errors);
111        errors.is_empty().then(|| value.into_owned())
112    }
113}
114
115impl Loader for EmbeddedLoader {
116    fn lookup_complete(
117        &self,
118        lang: &LanguageIdentifier,
119        text_id: &str,
120        args: Option<&Arguments<'_>>,
121    ) -> String {
122        self.try_lookup_complete(lang, text_id, args)
123            .unwrap_or_else(|| format!("Unknown localization key: {text_id:?}"))
124    }
125
126    fn try_lookup_complete(
127        &self,
128        lang: &LanguageIdentifier,
129        text_id: &str,
130        args: Option<&Arguments<'_>>,
131    ) -> Option<String> {
132        self.negotiate(lang)
133            .into_iter()
134            .find_map(|locale| Self::format(locale, text_id, args))
135            .or_else(|| {
136                let fallback = self
137                    .locales
138                    .iter()
139                    .find(|locale| locale.id == self.fallback);
140                (*lang != self.fallback)
141                    .then_some(fallback)
142                    .flatten()
143                    .and_then(|locale| Self::format(locale, text_id, args))
144            })
145    }
146
147    fn locales(&self) -> Box<dyn Iterator<Item = &LanguageIdentifier> + '_> {
148        Box::new(self.locales.iter().map(|locale| &locale.id))
149    }
150}
151
152/// A shareable message catalog that can be registered as an entry package.
153#[derive(Clone)]
154pub struct Catalog {
155    loader: Arc<dyn Loader + Send + Sync>,
156    default_locale: LanguageIdentifier,
157}
158
159impl Catalog {
160    /// Uses the loader's language negotiation and fallback policy.
161    pub fn new(
162        loader: impl Loader + Send + Sync + 'static,
163        default_locale: LanguageIdentifier,
164    ) -> Self {
165        Self {
166            loader: Arc::new(loader),
167            default_locale,
168        }
169    }
170
171    /// Sets the default locale for this catalog and its entry package.
172    pub fn locale(mut self, locale: LanguageIdentifier) -> Self {
173        self.default_locale = locale;
174        self
175    }
176
177    /// Formats a message or attribute in the entry's current language.
178    ///
179    /// Application messages override library messages. Without an i18n package,
180    /// this catalog uses its default locale. Missing messages display their IDs.
181    pub fn text(&self, id: impl AsRef<str>) -> String {
182        let id = id.as_ref();
183        self.lookup(id, None).unwrap_or_else(|| id.to_owned())
184    }
185
186    /// Formats a complete message with named Fluent arguments.
187    pub fn format<'a>(
188        &self,
189        id: &str,
190        args: impl IntoIterator<Item = (&'static str, FluentValue<'a>)>,
191    ) -> String {
192        let args = args
193            .into_iter()
194            .map(|(key, value)| (Cow::Borrowed(key), value))
195            .collect();
196        self.lookup(id, Some(&args))
197            .unwrap_or_else(|| id.to_owned())
198    }
199
200    fn lookup(&self, id: &str, args: Option<&Arguments<'_>>) -> Option<String> {
201        if let Some(i18n) = I18n::try_current() {
202            let (application, locale) = i18n.snapshot();
203            if !Arc::ptr_eq(&application, &self.loader) {
204                if let Some(value) = application.try_lookup_complete(&locale, id, args) {
205                    return Some(value);
206                }
207            }
208            self.loader.try_lookup_complete(&locale, id, args)
209        } else {
210            self.loader
211                .try_lookup_complete(&self.default_locale, id, args)
212        }
213    }
214}
215
216struct Localization {
217    catalog: Catalog,
218    locale: LanguageIdentifier,
219}
220
221struct LocalizationPlugin {
222    _owner: StateOwner,
223    current: I18n,
224}
225
226impl Plugin for LocalizationPlugin {}
227
228impl TesseraPackage for Catalog {
229    fn register(self, registry: &mut EntryRegistry) {
230        let owner = StateOwner::new();
231        let state = owner.state_with_key((), || Localization {
232            locale: self.default_locale.clone(),
233            catalog: self,
234        });
235        registry.register_plugin(LocalizationPlugin {
236            _owner: owner,
237            current: I18n(state),
238        });
239    }
240}
241
242/// A copyable controller for the language owned by an application entry.
243#[derive(Clone, Copy, PartialEq)]
244pub struct I18n(State<Localization>);
245
246impl I18n {
247    /// Returns the current entry's localization controller.
248    ///
249    /// Panics when no localization catalog was registered with the entry.
250    pub fn current() -> Self {
251        Self::try_current().expect("register a localization catalog with EntryPoint::package")
252    }
253
254    /// Returns a controller when the current entry has a localization catalog.
255    pub fn try_current() -> Option<Self> {
256        try_with_plugin::<LocalizationPlugin, _>(|plugin| plugin.current)
257    }
258
259    /// Returns the requested locale, which may use catalog fallback.
260    pub fn locale(self) -> LanguageIdentifier {
261        self.0.with(|localization| localization.locale.clone())
262    }
263
264    /// Changes the locale and invalidates readers when it changes.
265    pub fn set_locale(self, locale: LanguageIdentifier) {
266        if self.locale() != locale {
267            self.0.with_mut(|localization| localization.locale = locale);
268        }
269    }
270
271    /// Lists available application catalog locales.
272    pub fn locales(self) -> Vec<LanguageIdentifier> {
273        let (loader, _) = self.snapshot();
274        loader.locales().cloned().collect()
275    }
276
277    fn snapshot(self) -> (Arc<dyn Loader + Send + Sync>, LanguageIdentifier) {
278        self.0.with(|localization| {
279            (
280                Arc::clone(&localization.catalog.loader),
281                localization.locale.clone(),
282            )
283        })
284    }
285}
286
287#[cfg(test)]
288mod tests {
289    use super::*;
290
291    static LOCALES: &[(&str, &[&str])] = &[
292        (
293            "en",
294            &[
295                "greeting = Hello\n    .title = Welcome\n",
296                "only-en = Fallback\n",
297            ],
298        ),
299        ("zh-CN", &["greeting = 你好\n"]),
300    ];
301
302    #[test]
303    fn embedded_locales_parse_on_first_lookup_and_fall_back_per_message() {
304        let loader = EmbeddedLoader::new(LOCALES, langid!("en"));
305        assert_eq!(
306            loader
307                .try_lookup(&langid!("en"), "greeting.title")
308                .as_deref(),
309            Some("Welcome")
310        );
311        assert!(loader.locales[1].bundle.get().is_none());
312        assert_eq!(
313            loader.try_lookup(&langid!("zh-CN"), "greeting").as_deref(),
314            Some("你好")
315        );
316        assert_eq!(
317            loader.try_lookup(&langid!("zh-CN"), "only-en").as_deref(),
318            Some("Fallback")
319        );
320        assert_eq!(
321            loader.try_lookup(&langid!("fr"), "greeting").as_deref(),
322            Some("Hello")
323        );
324        assert_eq!(loader.try_lookup(&langid!("en"), "missing"), None);
325    }
326}