git.lucas.co / cce-core
GUI-free half of the cce toolkit: config, input, IPC, spec parsers
git clone https://git.lucas.co/cce-core.git

src/l10n.rs (8.4K)

  1 //! Translatable strings: a message catalogue per domain, in Project Fluent's format
  2 //! (`docs/rfc-accessibility-locale.md` in cce-ui, phase 5).
  3 //!
  4 //! A domain is one program or library (`cce-ui`, an app's name). Its English messages are
  5 //! built in ([`Catalog::new`], usually `include_str!` of its `en-US/<domain>.ftl`), so it
  6 //! always has every message; a translation is a file a translator drops in, found on first
  7 //! use for the user's locale ([`crate::locale::locale`]) — `ja-JP`, then `ja` — in, in
  8 //! order:
  9 //!
 10 //! 1. `$CCE_LOCALE_DIR/<tag>/<domain>.ftl`
 11 //! 2. `$XDG_DATA_HOME/cce/locale/<tag>/<domain>.ftl` (else `~/.local/share/…`)
 12 //! 3. `<each of $XDG_DATA_DIRS>/cce/locale/<tag>/<domain>.ftl` (else `/usr/local/share`,
 13 //!    `/usr/share`)
 14 //!
 15 //! The first file found for each tag is taken. A message is looked up most specific first
 16 //! and falls back to English, then to its own id — so a missing one shows as its id, which
 17 //! is a bug to see rather than a blank.
 18 //!
 19 //! **An id is the message's identity, never its English text.** Code that acts on a choice
 20 //! keys the action by something else (cce-ui's menus carry a `ContextAction`); translating
 21 //! a label must change what it says and nothing it does.
 22 //!
 23 //! Placeables are not wrapped in Unicode isolation marks (Fluent's default): the renderer
 24 //! would draw them, and the toolkit's own messages put no user text inside right-to-left
 25 //! sentences yet. A page has no files, so in the browser a catalogue is English unless
 26 //! [`Catalog::add_translation`] gives it more. Under `cfg(test)` (or the `test-isolation`
 27 //! feature) no directory is read: a suite never sees the machine's translations.
 28 
 29 use std::sync::{OnceLock, RwLock};
 30 
 31 use fluent_bundle::concurrent::FluentBundle;
 32 use fluent_bundle::{FluentArgs, FluentResource};
 33 use unic_langid::LanguageIdentifier;
 34 
 35 /// One domain's messages: its English, and what was found for the user's locale.
 36 pub struct Catalog {
 37     domain: &'static str,
 38     english: &'static str,
 39     /// Most specific first, English last. Built on first use.
 40     bundles: OnceLock<RwLock<Vec<FluentBundle<FluentResource>>>>,
 41 }
 42 
 43 impl Catalog {
 44     /// A domain's catalogue with its English messages (Fluent source). Nothing is read
 45     /// until the first lookup.
 46     pub const fn new(domain: &'static str, english: &'static str) -> Catalog {
 47         Catalog { domain, english, bundles: OnceLock::new() }
 48     }
 49 
 50     /// The domain's name, what its translation files are called.
 51     pub fn domain(&self) -> &'static str {
 52         self.domain
 53     }
 54 
 55     /// The message `id` in the user's language, with no arguments.
 56     pub fn get(&self, id: &str) -> String {
 57         self.format(id, &[])
 58     }
 59 
 60     /// The message `id` with its `{ $name }` placeables filled from `args`.
 61     pub fn format(&self, id: &str, args: &[(&str, &str)]) -> String {
 62         let bundles = self.bundles().read().unwrap_or_else(|e| e.into_inner());
 63         let fargs = (!args.is_empty()).then(|| {
 64             let mut a = FluentArgs::new();
 65             for (k, v) in args {
 66                 a.set(*k, *v);
 67             }
 68             a
 69         });
 70         for bundle in bundles.iter() {
 71             let Some(pattern) = bundle.get_message(id).and_then(|m| m.value()) else { continue };
 72             let mut errors = Vec::new();
 73             let out = bundle.format_pattern(pattern, fargs.as_ref(), &mut errors);
 74             if !errors.is_empty() {
 75                 log::warn!("l10n: {}: message {id}: {errors:?}", self.domain);
 76             }
 77             return out.into_owned();
 78         }
 79         log::warn!("l10n: {}: no message {id}", self.domain);
 80         id.to_string()
 81     }
 82 
 83     /// Add a translation for `tag` (a BCP 47 tag) ahead of everything found so far — what
 84     /// a page, which has no files, or a test does.
 85     pub fn add_translation(&self, tag: &str, source: &str) {
 86         let Some(bundle) = bundle_for(self.domain, tag, source.to_string()) else { return };
 87         let mut bundles = self.bundles().write().unwrap_or_else(|e| e.into_inner());
 88         bundles.insert(0, bundle);
 89     }
 90 
 91     fn bundles(&self) -> &RwLock<Vec<FluentBundle<FluentResource>>> {
 92         self.bundles.get_or_init(|| {
 93             let mut bundles = Vec::new();
 94             for tag in chain(crate::locale::locale()) {
 95                 if let Some(source) = find(self.domain, &tag) {
 96                     if let Some(b) = bundle_for(self.domain, &tag, source) {
 97                         bundles.push(b);
 98                     }
 99                 }
100             }
101             if let Some(b) = bundle_for(self.domain, "en-US", self.english.to_string()) {
102                 bundles.push(b);
103             }
104             RwLock::new(bundles)
105         })
106     }
107 }
108 
109 /// The tags a locale is looked up under, most specific first: `ja-JP` then `ja`. English
110 /// is built in, so it is not looked for.
111 pub fn chain(locale: &str) -> Vec<String> {
112     let mut out = Vec::new();
113     let mut parts: Vec<&str> = locale.split('-').filter(|p| !p.is_empty()).collect();
114     while !parts.is_empty() {
115         let tag = parts.join("-");
116         if tag != "en-US" && tag != "en" {
117             out.push(tag);
118         }
119         parts.pop();
120     }
121     out
122 }
123 
124 fn bundle_for(domain: &str, tag: &str, source: String) -> Option<FluentBundle<FluentResource>> {
125     let lang: LanguageIdentifier = tag.parse().unwrap_or_else(|_| "en-US".parse().unwrap());
126     let resource = match FluentResource::try_new(source) {
127         Ok(r) => r,
128         Err((r, errors)) => {
129             log::warn!("l10n: {domain} ({tag}): {} syntax errors, the rest kept: {errors:?}", errors.len());
130             r
131         }
132     };
133     let mut bundle = FluentBundle::new_concurrent(vec![lang]);
134     bundle.set_use_isolating(false);
135     if let Err(errors) = bundle.add_resource(resource) {
136         log::warn!("l10n: {domain} ({tag}): {errors:?}");
137     }
138     Some(bundle)
139 }
140 
141 /// The directories a translation is looked for in, in order.
142 #[cfg(not(target_arch = "wasm32"))]
143 pub fn search_dirs() -> Vec<std::path::PathBuf> {
144     use std::path::PathBuf;
145     let env = |k: &str| std::env::var_os(k).filter(|v| !v.is_empty());
146     let mut dirs = Vec::new();
147     if let Some(d) = env("CCE_LOCALE_DIR") {
148         dirs.push(PathBuf::from(d));
149     }
150     let data_home = env("XDG_DATA_HOME").map(PathBuf::from).or_else(|| env("HOME").map(|h| PathBuf::from(h).join(".local/share")));
151     if let Some(d) = data_home {
152         dirs.push(d.join("cce/locale"));
153     }
154     let data_dirs = env("XDG_DATA_DIRS").map(|v| v.to_string_lossy().into_owned()).unwrap_or_else(|| "/usr/local/share:/usr/share".into());
155     for d in data_dirs.split(':').filter(|d| !d.is_empty()) {
156         dirs.push(PathBuf::from(d).join("cce/locale"));
157     }
158     dirs
159 }
160 
161 /// The first translation file for `domain` in `tag` along [`search_dirs`].
162 fn find(domain: &str, tag: &str) -> Option<String> {
163     #[cfg(any(test, feature = "test-isolation", target_arch = "wasm32"))]
164     {
165         let _ = (domain, tag);
166         None
167     }
168     #[cfg(not(any(test, feature = "test-isolation", target_arch = "wasm32")))]
169     {
170         search_dirs().into_iter().find_map(|d| std::fs::read_to_string(d.join(tag).join(format!("{domain}.ftl"))).ok())
171     }
172 }
173 
174 #[cfg(test)]
175 mod tests {
176     use super::*;
177 
178     const EN: &str = "greeting = Hello\nfile-label = File: { $file }\nonly-english = Only in English\n";
179 
180     #[test]
181     fn a_message_is_found_formatted_and_falls_back() {
182         let c = Catalog::new("test-domain", EN);
183         assert_eq!(c.get("greeting"), "Hello");
184         assert_eq!(c.format("file-label", &[("file", "a.kdl")]), "File: a.kdl", "no isolation marks");
185         assert_eq!(c.get("no-such-message"), "no-such-message", "a missing message is its id");
186 
187         c.add_translation("de", "greeting = Hallo\nfile-label = Datei: { $file }\n");
188         assert_eq!(c.get("greeting"), "Hallo");
189         assert_eq!(c.format("file-label", &[("file", "a.kdl")]), "Datei: a.kdl");
190         assert_eq!(c.get("only-english"), "Only in English", "what the translation lacks is English");
191     }
192 
193     #[test]
194     fn a_broken_translation_keeps_what_parses() {
195         let c = Catalog::new("test-domain", EN);
196         c.add_translation("fr", "greeting = Bonjour\nthis is not fluent\n");
197         assert_eq!(c.get("greeting"), "Bonjour");
198     }
199 
200     #[test]
201     fn a_locale_is_looked_up_from_most_to_least_specific() {
202         assert_eq!(chain("ja-JP"), ["ja-JP", "ja"]);
203         assert_eq!(chain("zh-Hant-TW"), ["zh-Hant-TW", "zh-Hant", "zh"]);
204         assert_eq!(chain("en-US"), Vec::<String>::new(), "English is built in");
205         assert_eq!(chain("en-GB"), ["en-GB"]);
206     }
207 }