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 }