druid/
localization.rs

1// Copyright 2019 The Druid Authors.
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Localization handling.
16//!
17//! Localization is backed by [Fluent], via [fluent-rs].
18//!
19//! In Druid, the main way you will deal with localization is via the
20//! [`LocalizedString`] struct.
21//!
22//! You construct a [`LocalizedString`] with a key, which identifies a 'message'
23//! in your `.flt` files. If your string requires arguments, you supply it with
24//! closures that can extract those arguments from the current [`Env`] and
25//! [`Data`].
26//!
27//! At runtime, you resolve your [`LocalizedString`] into an actual string,
28//! passing it the current [`Env`] and [`Data`].
29//!
30//!
31//! [Fluent]: https://projectfluent.org
32//! [fluent-rs]: https://github.com/projectfluent/fluent-rs
33//! [`Data`]: crate::Data
34
35use std::collections::HashMap;
36use std::sync::Arc;
37use std::{fs, io};
38
39use tracing::{debug, error, warn};
40
41use crate::{Application, ArcStr, Env};
42
43use fluent_bundle::{
44    FluentArgs, FluentBundle, FluentError, FluentMessage, FluentResource, FluentValue,
45};
46use fluent_langneg::{negotiate_languages, NegotiationStrategy};
47use fluent_syntax::ast::Pattern as FluentPattern;
48use unic_langid::LanguageIdentifier;
49
50// Localization looks for string files in druid/resources, but this path is hardcoded;
51// it will only work if you're running an example from the druid/ directory.
52// At some point we will need to bundle strings with applications, and choose
53// the path dynamically.
54static FALLBACK_STRINGS: &str = include_str!("../resources/i18n/en-US/builtin.ftl");
55
56/// Provides access to the localization strings for the current locale.
57#[allow(dead_code)]
58pub(crate) struct L10nManager {
59    // these two are not currently used; will be used when we let the user
60    // add additional localization files.
61    res_mgr: ResourceManager,
62    resources: Vec<String>,
63    current_bundle: BundleStack,
64    current_locale: LanguageIdentifier,
65}
66
67/// Manages a collection of localization files.
68struct ResourceManager {
69    resources: HashMap<String, Arc<FluentResource>>,
70    locales: Vec<LanguageIdentifier>,
71    default_locale: LanguageIdentifier,
72    path_scheme: String,
73}
74
75//NOTE: instead of a closure, at some point we can use something like a lens for this.
76//TODO: this is an Arc so that it can be clone, which is a bound on things like `Menu`.
77/// A closure that generates a localization value.
78type ArgClosure<T> = Arc<dyn Fn(&T, &Env) -> FluentValue<'static> + 'static>;
79
80/// Wraps a closure that generates an argument for localization.
81#[derive(Clone)]
82struct ArgSource<T>(ArgClosure<T>);
83
84/// A string that can be localized based on the current locale.
85///
86/// At its simplest, a `LocalizedString` is a key that can be resolved
87/// against a map of localized strings for a given locale.
88#[derive(Debug, Clone)]
89pub struct LocalizedString<T> {
90    pub(crate) key: &'static str,
91    placeholder: Option<ArcStr>,
92    args: Option<Vec<(&'static str, ArgSource<T>)>>,
93    resolved: Option<ArcStr>,
94    resolved_lang: Option<LanguageIdentifier>,
95}
96
97/// A stack of localization resources, used for fallback.
98struct BundleStack(Vec<FluentBundle<Arc<FluentResource>>>);
99
100impl BundleStack {
101    fn get_message(&self, id: &str) -> Option<FluentMessage> {
102        self.0.iter().flat_map(|b| b.get_message(id)).next()
103    }
104
105    fn format_pattern(
106        &self,
107        id: &str,
108        pattern: &FluentPattern<&str>,
109        args: Option<&FluentArgs>,
110        errors: &mut Vec<FluentError>,
111    ) -> String {
112        for bundle in self.0.iter() {
113            if bundle.has_message(id) {
114                return bundle.format_pattern(pattern, args, errors).to_string();
115            }
116        }
117        format!("localization failed for key '{id}'")
118    }
119}
120
121//NOTE: much of this is adapted from https://github.com/projectfluent/fluent-rs/blob/master/fluent-resmgr/src/resource_manager.rs
122impl ResourceManager {
123    /// Loads a new localization resource from disk, as needed.
124    fn get_resource(&mut self, res_id: &str, locale: &str) -> Arc<FluentResource> {
125        let path = self
126            .path_scheme
127            .replace("{locale}", locale)
128            .replace("{res_id}", res_id);
129        if let Some(res) = self.resources.get(&path) {
130            res.clone()
131        } else {
132            let string = fs::read_to_string(&path).unwrap_or_else(|_| {
133                if (res_id, locale) == ("builtin.ftl", "en-US") {
134                    FALLBACK_STRINGS.to_string()
135                } else {
136                    error!("missing resource {}/{}", locale, res_id);
137                    String::new()
138                }
139            });
140            let res = match FluentResource::try_new(string) {
141                Ok(res) => Arc::new(res),
142                Err((res, _err)) => Arc::new(res),
143            };
144            self.resources.insert(path, res.clone());
145            res
146        }
147    }
148
149    /// Return the best localization bundle for the provided `LanguageIdentifier`.
150    fn get_bundle(&mut self, locale: &LanguageIdentifier, resource_ids: &[String]) -> BundleStack {
151        let resolved_locales = self.resolve_locales(locale.clone());
152        debug!("resolved: {}", PrintLocales(resolved_locales.as_slice()));
153        let mut stack = Vec::new();
154        for locale in &resolved_locales {
155            let mut bundle = FluentBundle::new(resolved_locales.clone());
156            for res_id in resource_ids {
157                let res = self.get_resource(res_id, &locale.to_string());
158                bundle.add_resource(res).unwrap();
159            }
160            stack.push(bundle);
161        }
162        BundleStack(stack)
163    }
164
165    /// Given a locale, returns the best set of available locales.
166    pub(crate) fn resolve_locales(&self, locale: LanguageIdentifier) -> Vec<LanguageIdentifier> {
167        negotiate_languages(
168            &[locale],
169            &self.locales,
170            Some(&self.default_locale),
171            NegotiationStrategy::Filtering,
172        )
173        .into_iter()
174        .map(|l| l.to_owned())
175        .collect()
176    }
177}
178
179impl L10nManager {
180    /// Create a new localization manager.
181    ///
182    /// `resources` is a list of file names that contain strings. `base_dir`
183    /// is a path to a directory that includes per-locale subdirectories.
184    ///
185    /// This directory should be of the structure `base_dir/{locale}/{resource}`,
186    /// where '{locale}' is a valid BCP47 language tag, and {resource} is a `.ftl`
187    /// included in `resources`.
188    pub fn new(resources: Vec<String>, base_dir: &str) -> Self {
189        fn get_available_locales(base_dir: &str) -> Result<Vec<LanguageIdentifier>, io::Error> {
190            let mut locales = vec![];
191
192            let res_dir = fs::read_dir(base_dir)?;
193            for entry in res_dir.flatten() {
194                let path = entry.path();
195                if path.is_dir() {
196                    if let Some(name) = path.file_name() {
197                        if let Some(name) = name.to_str() {
198                            let langid: LanguageIdentifier = name.parse().expect("Parsing failed.");
199                            locales.push(langid);
200                        }
201                    }
202                }
203            }
204            Ok(locales)
205        }
206
207        let default_locale: LanguageIdentifier =
208            "en-US".parse().expect("failed to parse default locale");
209        let current_locale = Application::get_locale()
210            .parse()
211            .unwrap_or_else(|_| default_locale.clone());
212        let locales = get_available_locales(base_dir).unwrap_or_default();
213        debug!(
214            "available locales {}, current {}",
215            PrintLocales(&locales),
216            current_locale,
217        );
218        let mut path_scheme = base_dir.to_string();
219        path_scheme.push_str("/{locale}/{res_id}");
220
221        let mut res_mgr = ResourceManager {
222            resources: HashMap::new(),
223            path_scheme,
224            default_locale,
225            locales,
226        };
227
228        let current_bundle = res_mgr.get_bundle(&current_locale, &resources);
229
230        L10nManager {
231            res_mgr,
232            resources,
233            current_bundle,
234            current_locale,
235        }
236    }
237
238    /// Fetch a localized string from the current bundle by key.
239    ///
240    /// In general, this should not be used directly; [`LocalizedString`]
241    /// should be used for localization, and you should call
242    /// [`LocalizedString::resolve`] to update the string as required.
243    ///
244    ///[`LocalizedString`]: struct.LocalizedString.html
245    ///[`LocalizedString::resolve`]: struct.LocalizedString.html#method.resolve
246    pub fn localize<'args>(
247        &'args self,
248        key: &str,
249        args: impl Into<Option<&'args FluentArgs<'args>>>,
250    ) -> Option<ArcStr> {
251        let args = args.into();
252        let value = match self
253            .current_bundle
254            .get_message(key)
255            .and_then(|msg| msg.value())
256        {
257            Some(v) => v,
258            None => return None,
259        };
260        let mut errs = Vec::new();
261        let result = self
262            .current_bundle
263            .format_pattern(key, value, args, &mut errs);
264        for err in errs {
265            warn!("localization error {:?}", err);
266        }
267
268        // fluent inserts bidi controls when interpolating, and they can
269        // cause rendering issues; for now we just strip them.
270        // https://www.w3.org/International/questions/qa-bidi-unicode-controls#basedirection
271        const START_ISOLATE: char = '\u{2068}';
272        const END_ISOLATE: char = '\u{2069}';
273        if args.is_some() && result.chars().any(|c| c == START_ISOLATE) {
274            Some(
275                result
276                    .chars()
277                    .filter(|c| c != &START_ISOLATE && c != &END_ISOLATE)
278                    .collect::<String>()
279                    .into(),
280            )
281        } else {
282            Some(result.into())
283        }
284    }
285    //TODO: handle locale change
286}
287
288impl std::fmt::Debug for L10nManager {
289    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
290        f.debug_struct("L10nManager")
291            .field("resources", &self.resources)
292            .field("res_mgr.locales", &self.res_mgr.locales)
293            .field("current_locale", &self.current_locale)
294            .finish()
295    }
296}
297
298impl<T> LocalizedString<T> {
299    /// Create a new `LocalizedString` with the given key.
300    pub const fn new(key: &'static str) -> Self {
301        LocalizedString {
302            key,
303            args: None,
304            placeholder: None,
305            resolved: None,
306            resolved_lang: None,
307        }
308    }
309
310    /// Add a placeholder value. This will be used if localization fails.
311    ///
312    /// This is intended for use during prototyping.
313    pub fn with_placeholder(mut self, placeholder: impl Into<ArcStr>) -> Self {
314        self.placeholder = Some(placeholder.into());
315        self
316    }
317
318    /// Return the localized value for this string, or the placeholder, if
319    /// the localization is missing, or the key if there is no placeholder.
320    pub fn localized_str(&self) -> ArcStr {
321        self.resolved
322            .clone()
323            .or_else(|| self.placeholder.clone())
324            .unwrap_or_else(|| self.key.into())
325    }
326
327    /// Add a named argument and a corresponding closure. This closure
328    /// is a function that will return a value for the given key from the current
329    /// environment and data.
330    pub fn with_arg(
331        mut self,
332        key: &'static str,
333        f: impl Fn(&T, &Env) -> FluentValue<'static> + 'static,
334    ) -> Self {
335        self.args
336            .get_or_insert(Vec::new())
337            .push((key, ArgSource(Arc::new(f))));
338        self
339    }
340
341    /// Lazily compute the localized value for this string based on the provided
342    /// environment and data.
343    ///
344    /// Returns `true` if the current value of the string has changed.
345    pub fn resolve(&mut self, data: &T, env: &Env) -> bool {
346        //TODO: this recomputes the string if either the language has changed,
347        //or *anytime* we have arguments. Ideally we would be using a lens
348        //to only recompute when our actual data has changed.
349        let manager = match env.localization_manager() {
350            Some(manager) => manager,
351            None => return false,
352        };
353
354        if self.args.is_some() || self.resolved_lang.as_ref() != Some(&manager.current_locale) {
355            let args: Option<FluentArgs> = self
356                .args
357                .as_ref()
358                .map(|a| a.iter().map(|(k, v)| (*k, (v.0)(data, env))).collect());
359
360            self.resolved_lang = Some(manager.current_locale.clone());
361            let next = manager.localize(self.key, args.as_ref());
362            let result = next != self.resolved;
363            self.resolved = next;
364            result
365        } else {
366            false
367        }
368    }
369}
370
371impl<T> std::fmt::Debug for ArgSource<T> {
372    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
373        write!(f, "Arg Resolver {:p}", self.0)
374    }
375}
376
377/// Helper to impl display for slices of displayable things.
378struct PrintLocales<'a, T>(&'a [T]);
379
380impl<'a, T: std::fmt::Display> std::fmt::Display for PrintLocales<'a, T> {
381    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
382        write!(f, "[")?;
383        let mut prev = false;
384        for l in self.0 {
385            if prev {
386                write!(f, ", ")?;
387            }
388            prev = true;
389            write!(f, "{l}")?;
390        }
391        write!(f, "]")
392    }
393}
394
395#[cfg(test)]
396mod tests {
397    use super::*;
398    use test_log::test;
399
400    #[test]
401    fn resolve() {
402        let en_us: LanguageIdentifier = "en-US".parse().unwrap();
403        let en_ca: LanguageIdentifier = "en-CA".parse().unwrap();
404        let en_gb: LanguageIdentifier = "en-GB".parse().unwrap();
405        let fr_fr: LanguageIdentifier = "fr-FR".parse().unwrap();
406        let pt_pt: LanguageIdentifier = "pt-PT".parse().unwrap();
407
408        let resmgr = ResourceManager {
409            resources: HashMap::new(),
410            locales: vec![en_us.clone(), en_ca.clone(), en_gb.clone(), fr_fr.clone()],
411            default_locale: en_us.clone(),
412            path_scheme: String::new(),
413        };
414
415        let en_za: LanguageIdentifier = "en-GB".parse().unwrap();
416        let cn_hk: LanguageIdentifier = "cn-HK".parse().unwrap();
417        let fr_ca: LanguageIdentifier = "fr-CA".parse().unwrap();
418
419        assert_eq!(
420            resmgr.resolve_locales(en_ca.clone()),
421            vec![en_ca.clone(), en_us.clone(), en_gb.clone()]
422        );
423        assert_eq!(
424            resmgr.resolve_locales(en_za),
425            vec![en_gb, en_us.clone(), en_ca]
426        );
427        assert_eq!(
428            resmgr.resolve_locales(fr_ca),
429            vec![fr_fr.clone(), en_us.clone()]
430        );
431        assert_eq!(
432            resmgr.resolve_locales(fr_fr.clone()),
433            vec![fr_fr, en_us.clone()]
434        );
435        assert_eq!(resmgr.resolve_locales(cn_hk), vec![en_us.clone()]);
436        assert_eq!(resmgr.resolve_locales(pt_pt), vec![en_us]);
437    }
438}