Skip to main content

cranpose_localization/
catalog.rs

1use std::{
2    collections::{BTreeMap, BTreeSet},
3    rc::Rc,
4    sync::Arc,
5};
6
7use fluent_bundle::{FluentArgs, FluentBundle, FluentResource};
8use fluent_langneg::{NegotiationStrategy, negotiate_languages};
9use fluent_syntax::ast::Entry;
10use unic_langid::LanguageIdentifier;
11
12use crate::{
13    Argument, Locale, LocalizationError, Message, locale::preview_text, message::fluent_args,
14};
15
16type Resources = BTreeMap<LanguageIdentifier, BTreeMap<Arc<str>, Vec<Arc<FluentResource>>>>;
17type Prepared = BTreeMap<Arc<str>, Vec<FluentBundle<Arc<FluentResource>>>>;
18
19/// One embedded or application-loaded Fluent resource.
20#[derive(Clone, Copy)]
21pub struct Resource<'a> {
22    /// Language tag, for example `fr-CA`.
23    pub locale: &'a str,
24    /// Owning package, or the package being overridden by the application.
25    pub namespace: &'a str,
26    /// Complete UTF-8 Fluent source.
27    pub source: &'a str,
28}
29
30struct CatalogData {
31    fallback: Locale,
32    languages: Vec<LanguageIdentifier>,
33    resources: Resources,
34    parents: Vec<Catalog>,
35}
36
37/// Immutable, shareable parsed catalogs. Replacing a catalog replaces its cache identity.
38#[derive(Clone)]
39pub struct Catalog(Arc<CatalogData>, Arc<[Language]>);
40
41/// A supported application language and its native display name.
42#[derive(Clone, Debug, PartialEq, Eq)]
43pub struct Language {
44    tag: Arc<str>,
45    name: Arc<str>,
46    locale: Locale,
47}
48
49impl Language {
50    /// The canonical language tag used in catalogs and saved preferences.
51    pub fn tag(&self) -> &str {
52        &self.tag
53    }
54
55    /// The native display name declared by the application.
56    pub fn name(&self) -> &str {
57        &self.name
58    }
59
60    /// The validated locale used to select this language.
61    pub fn locale(&self) -> &Locale {
62        &self.locale
63    }
64}
65
66impl PartialEq for Catalog {
67    fn eq(&self, other: &Self) -> bool {
68        Arc::ptr_eq(&self.0, &other.0) && (Arc::ptr_eq(&self.1, &other.1) || self.1 == other.1)
69    }
70}
71
72impl Catalog {
73    /// Parses catalogs once, rejecting malformed resources and duplicate definitions.
74    /// Missing translations fall back to `fallback`, then the message's inline source.
75    pub fn from_resources(
76        fallback: &str,
77        resources: &[Resource<'_>],
78    ) -> Result<Self, LocalizationError> {
79        let fallback = Locale::parse(fallback)?;
80        let mut parsed: Resources = BTreeMap::new();
81        for resource in resources {
82            let language = Locale::parse(resource.locale)?.language;
83            let source = FluentResource::try_new(resource.source.to_owned())
84                .map_err(|(_, errors)| resource_error(resource, format!("{errors:?}")))?;
85            parsed
86                .entry(language)
87                .or_default()
88                .entry(Arc::from(resource.namespace))
89                .or_default()
90                .push(Arc::new(source));
91        }
92        validate_duplicates(&parsed)?;
93        let languages: Vec<_> = parsed.keys().cloned().collect();
94        let choices = languages
95            .iter()
96            .map(|language| {
97                let tag: Arc<str> = language.to_string().into();
98                Language {
99                    name: tag.clone(),
100                    tag,
101                    locale: Locale {
102                        language: language.clone(),
103                        preview: crate::PreviewMode::None,
104                    },
105                }
106            })
107            .collect();
108        Ok(Self(
109            Arc::new(CatalogData {
110                fallback,
111                languages,
112                resources: parsed,
113                parents: Vec::new(),
114            }),
115            choices,
116        ))
117    }
118
119    /// Declares supported languages in presentation order with their native names.
120    /// Every catalog language must occur exactly once; dependency catalogs do not
121    /// add choices to an application's language selector.
122    pub fn with_languages(mut self, entries: &[(&str, &str)]) -> Result<Self, LocalizationError> {
123        let mut seen = BTreeSet::new();
124        let mut choices = Vec::with_capacity(entries.len());
125        for (tag, name) in entries {
126            let locale = Locale::parse(tag)?;
127            if name.trim().is_empty()
128                || !self.0.languages.contains(&locale.language)
129                || !seen.insert(locale.language.clone())
130            {
131                return Err(LocalizationError::InvalidResource {
132                    resource: "localization.toml".into(),
133                    detail: format!("invalid or duplicate language `{tag}`"),
134                });
135            }
136            choices.push(Language {
137                tag: locale.to_string().into(),
138                name: (*name).into(),
139                locale,
140            });
141        }
142        if seen.len() != self.0.languages.len() {
143            return Err(LocalizationError::InvalidResource {
144                resource: "localization.toml".into(),
145                detail: "language declaration must include every catalog language".into(),
146            });
147        }
148        self.1 = choices.into();
149        Ok(self)
150    }
151
152    /// Supported application languages, in their declared presentation order.
153    pub fn languages(&self) -> &[Language] {
154        &self.1
155    }
156
157    /// Adds a library's catalogs below application overrides without copying its resources.
158    /// Lookup prefers an application message in the same language before the library's.
159    pub fn with_fallback(self, library: &Catalog) -> Self {
160        let choices = self.1.clone();
161        let mut languages = self.0.languages.clone();
162        languages.extend(library.0.languages.iter().cloned());
163        languages.sort();
164        languages.dedup();
165        Self(
166            Arc::new(CatalogData {
167                fallback: self.0.fallback.clone(),
168                languages,
169                resources: BTreeMap::new(),
170                parents: vec![self, library.clone()],
171            }),
172            choices,
173        )
174    }
175
176    fn prepare(&self, language: &LanguageIdentifier, output: &mut Prepared) {
177        if let Some(namespaces) = self.0.resources.get(language) {
178            for (namespace, sources) in namespaces {
179                let mut bundle = crate::new_bundle(language.clone());
180                for resource in sources {
181                    bundle
182                        .add_resource(Arc::clone(resource))
183                        .expect("catalog rejects duplicate entries");
184                }
185                output
186                    .entry(Arc::clone(namespace))
187                    .or_default()
188                    .push(bundle);
189            }
190        }
191        for parent in &self.0.parents {
192            parent.prepare(language, output);
193        }
194    }
195
196    /// Creates a thread-owned formatter for an explicit language.
197    pub fn translator(&self, locale: Locale) -> Translator {
198        self.negotiate(std::slice::from_ref(&locale))
199    }
200
201    /// Negotiates ordered user preferences, preserving script subtags.
202    /// An empty list selects the catalog's source language. The resulting formatter
203    /// stays on the calling thread; pass a cloned catalog to background workers.
204    pub fn negotiate(&self, preferences: &[Locale]) -> Translator {
205        let requested: Vec<_> = preferences.iter().map(|locale| &locale.language).collect();
206        let languages = negotiate_languages(
207            &requested,
208            &self.0.languages,
209            Some(&self.0.fallback.language),
210            NegotiationStrategy::Filtering,
211        );
212        let prepared = languages
213            .iter()
214            .map(|language| {
215                let mut prepared = Prepared::new();
216                self.prepare(language, &mut prepared);
217                prepared
218            })
219            .collect();
220        let locale = Locale {
221            language: languages.first().map_or_else(
222                || self.0.fallback.language.clone(),
223                |language| (*language).clone(),
224            ),
225            preview: preferences.first().map_or_default(|locale| locale.preview),
226        };
227        Translator(Rc::new(TranslatorData {
228            catalog: self.clone(),
229            locale,
230            prepared,
231            preferences: preferences.to_vec(),
232            #[cfg(feature = "formatting")]
233            formatters: std::cell::OnceCell::new(),
234        }))
235    }
236
237    /// The source language used when no requested translation is available.
238    pub fn fallback_locale(&self) -> &Locale {
239        &self.0.fallback
240    }
241}
242
243struct TranslatorData {
244    catalog: Catalog,
245    locale: Locale,
246    prepared: Vec<Prepared>,
247    preferences: Vec<Locale>,
248    #[cfg(feature = "formatting")]
249    formatters: std::cell::OnceCell<Result<Rc<crate::LocaleFormatters>, crate::FormatError>>,
250}
251
252/// A thread-owned formatter with no cross-thread locks during formatting.
253/// Construct one from a shared [`Catalog`] on each thread that needs translations.
254#[derive(Clone)]
255pub struct Translator(Rc<TranslatorData>);
256
257impl PartialEq for Translator {
258    fn eq(&self, other: &Self) -> bool {
259        Rc::ptr_eq(&self.0, &other.0)
260            || (self.0.catalog == other.0.catalog && self.0.preferences == other.0.preferences)
261    }
262}
263
264impl Translator {
265    /// The requested regional locale that selected this catalog language.
266    /// Data formatting retains region preferences such as `en-GB` even when UI
267    /// messages come from the more general `en` catalog.
268    pub fn formatting_locale(&self) -> &Locale {
269        self.0
270            .preferences
271            .iter()
272            .find(|locale| locale.language.language == self.0.locale.language.language)
273            .unwrap_or(&self.0.locale)
274    }
275
276    /// Reuses locale-aware data formatters across every consumer of this translator.
277    #[cfg(feature = "formatting")]
278    pub fn formatters(&self) -> Result<Rc<crate::LocaleFormatters>, crate::FormatError> {
279        self.0
280            .formatters
281            .get_or_init(|| crate::LocaleFormatters::new(self.formatting_locale()).map(Rc::new))
282            .clone()
283    }
284    /// The first selected catalog language and the requested preview settings.
285    /// Layout direction follows this effective language, including on fallback.
286    pub fn locale(&self) -> &Locale {
287        &self.0.locale
288    }
289
290    /// Adds library translations while retaining the original language preferences.
291    pub fn with_fallback(&self, library: &Catalog) -> Self {
292        self.0
293            .catalog
294            .clone()
295            .with_fallback(library)
296            .negotiate(&self.0.preferences)
297    }
298
299    /// Formats a message, trying negotiated catalogs and then its source.
300    /// Broken translations are skipped; a broken source returns an error.
301    pub fn format(
302        &self,
303        message: &Message,
304        arguments: &[Argument<'_>],
305    ) -> Result<String, LocalizationError> {
306        let args = fluent_args(arguments);
307        let text = self
308            .translated(message, &args)
309            .map_or_else(|| message.source(&args), Ok)?;
310        Ok(preview_text(text, self.0.locale.preview))
311    }
312
313    fn translated(&self, message: &Message, args: &FluentArgs<'_>) -> Option<String> {
314        for prepared in &self.0.prepared {
315            let Some(bundles) = prepared.get(message.namespace()) else {
316                continue;
317            };
318            for bundle in bundles {
319                let Some(pattern) = bundle.get_message(message.id()).and_then(|msg| msg.value())
320                else {
321                    continue;
322                };
323                let mut errors = Vec::new();
324                let text = bundle.format_pattern(pattern, Some(args), &mut errors);
325                if errors.is_empty() {
326                    return Some(text.into_owned());
327                }
328            }
329        }
330        None
331    }
332}
333
334fn validate_duplicates(resources: &Resources) -> Result<(), LocalizationError> {
335    for (language, namespaces) in resources {
336        for (namespace, sources) in namespaces {
337            let mut identifiers = BTreeSet::from(["NUMBER"]);
338            for source in sources {
339                for entry in source.entries() {
340                    let key = match entry {
341                        Entry::Message(message) => message.id.name,
342                        Entry::Term(term) => term.id.name,
343                        _ => continue,
344                    };
345                    if !identifiers.insert(key) {
346                        return Err(LocalizationError::InvalidResource {
347                            resource: format!("{language}/{namespace}"),
348                            detail: format!("duplicate or reserved entry `{key}`"),
349                        });
350                    }
351                }
352            }
353        }
354    }
355    Ok(())
356}
357
358fn resource_error(resource: &Resource<'_>, detail: String) -> LocalizationError {
359    LocalizationError::InvalidResource {
360        resource: format!("{}/{}", resource.locale, resource.namespace),
361        detail,
362    }
363}