zng_ext_l10n/lib.rs
1#![doc(html_favicon_url = "https://zng-ui.github.io/res/zng-logo-icon.png")]
2#![doc(html_logo_url = "https://zng-ui.github.io/res/zng-logo.png")]
3//!
4//! Localization service, [`l10n!`] and helpers.
5//!
6//! # Services
7//!
8//! Services this extension provides.
9//!
10//! * [`L10N`]
11//!
12//! # Crate
13//!
14#![doc = include_str!(concat!("../", std::env!("CARGO_PKG_README")))]
15#![warn(unused_extern_crates)]
16#![warn(missing_docs)]
17
18use std::{collections::HashMap, path::PathBuf, sync::Arc};
19
20use semver::Version;
21use zng_app::{
22 APP,
23 event::{Command, CommandMetaVar, EVENTS_L10N},
24 hn,
25 view_process::raw_events::RAW_LOCALE_CONFIG_CHANGED_EVENT,
26};
27use zng_env::on_process_start;
28use zng_layout::context::LayoutDirection;
29use zng_task as task;
30
31use zng_txt::Txt;
32use zng_var::{ArcEq, Var};
33
34#[doc(hidden)]
35pub use zng_ext_l10n_proc_macros::lang as __lang;
36
37#[doc(hidden)]
38pub use zng_ext_l10n_proc_macros::l10n as __l10n;
39
40#[doc(hidden)]
41pub use unic_langid;
42
43mod types;
44pub use types::*;
45
46mod service;
47use service::L10N_SV;
48
49mod sources;
50pub use sources::*;
51
52/// Localization service.
53pub struct L10N;
54
55on_process_start!(|args: &zng_env::ProcessStartArgs| {
56 if args.yield_until_app() {
57 return;
58 }
59
60 APP.on_init(hn!(|_| {
61 // integrate with commands localization
62 // this is the reason we don't lazy init L10N too.
63 EVENTS_L10N.init_l10n(|file, cmd, attr, txt| {
64 L10N.bind_command_meta(
65 LangFilePath {
66 pkg_name: file[0].into(),
67 pkg_version: file[1].parse().unwrap_or_else(|e| {
68 tracing::error!("invalid package version from command localization, {e}");
69 Version::new(0, 0, 0)
70 }),
71 file: file[2].into(),
72 },
73 cmd,
74 attr,
75 txt,
76 );
77 });
78
79 RAW_LOCALE_CONFIG_CHANGED_EVENT
80 .hook(|args| {
81 L10N_SV.read().set_sys_langs(&args.config);
82 true
83 })
84 .perm()
85 }));
86});
87
88///<span data-del-macro-root></span> Gets a variable that localizes and formats the text in a widget context.
89///
90/// # Syntax
91///
92/// Macro expects a message key string literal a *message template* string literal that is also used
93/// as fallback, followed by optional named format arguments `arg = <arg>,..`.
94///
95/// The message string syntax is the [Fluent Project] syntax, interpolations in the form of `"{$var}"` are resolved to a local `$var`.
96///
97/// ```
98/// # fn demo() {
99/// # use zng_ext_l10n::*;
100/// # use zng_var::*;
101/// # let _scope = zng_app::APP.minimal();
102/// let name = var("World");
103/// let msg = l10n!("file/id.attribute", "Hello {$name}!");
104/// # }
105/// ```
106///
107/// ## Key
108///
109/// This message key can be just a Fluent identifier, `"id"`, a Fluent attribute identifier can be added `"id.attr"`, and finally
110/// a file name can be added `"file/id"`. The key syntax is validated at compile time.
111///
112/// ### Id
113///
114/// The only required part of a key is the ID, it must contain at least one character, it must start with an ASCII letter
115/// and can be followed by any ASCII alphanumeric, _ and -, `[a-zA-Z][a-zA-Z0-9_-]*`.
116///
117/// ### Attribute
118///
119/// An attribute identifier can be suffixed on the id, separated by a `.` followed by an identifier of the same pattern as the
120/// id, `.[a-zA-Z][a-zA-Z0-9_-]*`.
121///
122/// ### File
123///
124/// An optional file name can be prefixed on the id, separated by a `/`, it can be a single file name, no extension.
125///
126/// Using the default directory resolver the key `"file/id.attr"` will search the id and attribute in the file `{dir}/{lang}/file.ftl`:
127///
128/// ```ftl
129/// id =
130/// .attr = message
131/// ```
132///
133/// And a key `"id.attr"` will be searched in the file `{dir}/{lang}/_.ftl`.
134///
135/// ### Package
136///
137/// The crate package name and version are also implicitly collected, when the message is requested from a different crate
138/// it is searched in `{dir}/{lang}/{pkg-name}/{pkg-version}/{file}.ftl`. Version matches any other version, the nearest is selected.
139///
140/// # Scrap Template
141///
142/// The `cargo zng l10n` tool can be used to collect all localizable text of Rust code files, it is a text based search that
143/// matches this macro name and the two first input literals, avoid renaming this macro to support scrapping, otherwise you will
144/// have to declare the template file manually.
145///
146/// The scrapper can also scrap comments, if the previous code line from a [`l10n!`] call is a comment starting with
147/// prefix `l10n-# ` the text the follows is collected, same for a comment in the same line of the [`l10n!`] call. Sections
148/// can be declared using `l10n-## `, all entries after a section comment are added to that section.
149///
150/// Standalone notes can be added to the top of the template file from anywhere using `l10n-{file_pattern}-### `, file pattern can be omitted,
151/// `l10n-### ` is equivalent to `l10n--### ` that matches the localization template used when no file is specified. Note that only standalone
152/// notes can have file names, sections and comments (`##` and `#`) are copied to each file of keys associated with the comment or section.
153///
154/// ```
155/// # use zng_ext_l10n::*;
156/// # use zng_var::*;
157/// # let _scope = zng_app::APP.minimal();
158/// #
159/// // l10n-### Standalone Note
160///
161/// // l10n-# Comment for `id`.
162/// let msg = l10n!("id", "id message");
163///
164/// // l10n-# Comment for `id.attr`.
165/// let msg = l10n!("id.attr", "attr message");
166///
167/// // l10n-## Section
168///
169/// let msg = l10n!("other", "other message"); // l10n-# Comment for `other`.
170/// ```
171///
172/// The example above is scrapped to a `template.ftl` file:
173///
174/// ```ftl
175/// ### Standalone Note
176///
177/// # Comment for `id`.
178/// #
179/// # attr:
180/// # Comment for `id.attr`.
181/// id = id message
182/// .attr = attr message
183///
184/// ## Section
185///
186/// # Commend for `other`.
187/// other = other message
188/// ```
189///
190/// You can install the scraper tool using cargo:
191///
192/// ```console
193/// cargo install cargo-zng
194/// ```
195///
196/// [Fluent Project]: https://projectfluent.org/fluent/guide/
197#[macro_export]
198macro_rules! l10n {
199 ($message_id:tt, $message:tt $(,)?) => {
200 $crate::__l10n! {
201 l10n_path { $crate }
202 message_id { $message_id }
203 message { $message }
204 }
205 };
206 ($message_id:tt, $message:tt, $($arg:ident = $arg_expr:expr),* $(,)?) => {
207 {
208 $(
209 let $arg = $arg_expr;
210 )*
211 $crate::__l10n! {
212 l10n_path { $crate }
213 message_id { $message_id }
214 message { $message }
215 }
216 }
217 };
218 ($($error:tt)*) => {
219 std::compile_error!(r#"expected ("id", "message") or ("id", "msg {$arg}", arg=expr)"#)
220 }
221}
222
223impl L10N {
224 /// Change the localization resources to `source`.
225 ///
226 /// All active variables and handles will be updated to use the new source.
227 pub fn load(&self, source: impl L10nSource) {
228 L10N_SV.write().load(source);
229 }
230
231 /// Start watching the `dir` for `dir/{lang}/*.ftl` and `dir/{lang}/deps/*/*/*.ftl` files.
232 ///
233 /// The [`available_langs`] variable maintains an up-to-date list of locale files found, the files
234 /// are only loaded when needed, and also are watched to update automatically.
235 ///
236 /// [`available_langs`]: Self::available_langs
237 pub fn load_dir(&self, dir: impl Into<PathBuf>) {
238 self.load(L10nDir::open(dir))
239 }
240
241 /// Load localization resources from a `.tar` or `.tar.gz` container.
242 ///
243 /// The expected container layout is `root_dir/{lang}/{file}.ftl` app files and `root_dir/{lang}/deps/{pkg-name}/{pkg-version}/{file}.ftl`
244 /// for dependencies, same as [`load_dir`], `root_dir` can have any name.
245 ///
246 /// The data can be embedded using [`include_bytes!`] or loaded into a `Vec<u8>` and must be in the `.tar` or `.tar.gz` format.
247 ///
248 /// [`load_dir`]: L10N::load_dir
249 #[cfg(feature = "tar")]
250 pub fn load_tar(&self, data: impl Into<L10nTarData>) {
251 self.load(L10nTar::load(data))
252 }
253
254 /// Available localization files.
255 ///
256 /// The value maps lang to one or more files, the files can be from the project `dir/{lang}/{file}.ftl` or from dependencies
257 /// `dir/{lang}/deps/{pkg-name/{pkg-version}/{file}.ftl`.
258 ///
259 /// Note that this map will include any file in the source dir that has a name that is a valid [`lang!`],
260 /// that includes the `template.ftl` file and test pseudo-locales such as `qps-ploc.ftl`.
261 pub fn available_langs(&self) -> Var<Arc<LangMap<HashMap<LangFilePath, PathBuf>>>> {
262 L10N_SV.write().available_langs()
263 }
264
265 /// Status of the [`available_langs`] list.
266 ///
267 /// This will be `NotAvailable` before the first call to [`load_dir`], then it changes to `Loading`, then
268 /// `Loaded` or `Error`.
269 ///
270 /// Note that this is the status of the resource list, not of each individual resource, you
271 /// can use [`LangResource::status`] for that.
272 ///
273 /// [`available_langs`]: Self::available_langs
274 /// [`load_dir`]: Self::load_dir
275 pub fn available_langs_status(&self) -> Var<LangResourceStatus> {
276 L10N_SV.write().available_langs_status()
277 }
278
279 /// Waits until [`available_langs_status`] is not `Loading`.
280 ///
281 /// [`available_langs_status`]: Self::available_langs_status
282 pub async fn wait_available_langs(&self) {
283 // wait potential `load_dir` start.
284 task::yield_now().await;
285
286 let status = self.available_langs_status();
287 while matches!(status.get(), LangResourceStatus::Loading) {
288 status.wait_update().await;
289 }
290 }
291
292 /// Gets a read-write variable that sets the preferred languages for the app.
293 /// Lang not available are ignored until they become available, the first language in the
294 /// vec is the most preferred.
295 ///
296 /// The value is the same as [`sys_lang`], if set the variable disconnects from system lang.
297 ///
298 /// Note that the [`LANG_VAR`] is used in message requests, the default value of that
299 /// context variable is this one.
300 ///
301 /// [`sys_lang`]: Self::sys_lang
302 pub fn app_lang(&self) -> Var<Langs> {
303 L10N_SV.read().app_lang()
304 }
305
306 /// Gets a read-only variable that is the current system language.
307 ///
308 /// The variable will update when the view-process notifies that the config has changed. Is
309 /// empty if the system locale cannot be retrieved.
310 pub fn sys_lang(&self) -> Var<Langs> {
311 L10N_SV.read().sys_lang()
312 }
313
314 /// Gets a read-only variable that is a localized message in the localization context
315 /// where the variable is first used. The variable will update when the contextual language changes.
316 ///
317 /// If the message has variable arguments they must be provided using [`L10nMessageBuilder::arg`], the
318 /// returned variable will also update when the arg variables update.
319 ///
320 /// Prefer using the [`l10n!`] macro instead of this method, the macro does compile time validation.
321 ///
322 /// # Params
323 ///
324 /// * `file`: Name of the resource file, in the default directory layout the file is searched at `dir/{lang}/{file}.ftl`, if
325 /// empty the file is searched at `dir/{lang}/_.ftl`. Only a single file name is valid, no other path components allowed.
326 /// Note that the file can also be a full [`LangFilePath`] that includes dependency package info. Those files are searched in
327 /// `dir/{lang}/deps/{pkg-name}/{pkg-version}/{file}.ftl`.
328 /// * `id`: Message identifier inside the resource file.
329 /// * `attribute`: Attribute of the identifier, leave empty to not use an attribute.
330 /// * `fallback`: Message to use when a localized message cannot be found.
331 ///
332 /// The `id` and `attribute` is only valid if it starts with letter `[a-zA-Z]`, followed by any letters, digits, _ or - `[a-zA-Z0-9_-]*`.
333 ///
334 /// Panics if any parameter is invalid.
335 pub fn message(
336 &self,
337 file: impl Into<LangFilePath>,
338 id: impl Into<Txt>,
339 attribute: impl Into<Txt>,
340 fallback: impl Into<Txt>,
341 ) -> L10nMessageBuilder {
342 L10nMessageBuilder {
343 file: file.into(),
344 id: id.into(),
345 attribute: attribute.into(),
346 fallback: fallback.into(),
347 args: vec![],
348 }
349 }
350
351 /// Function called by `l10n!`.
352 #[doc(hidden)]
353 pub fn l10n_message(
354 &self,
355 pkg_name: &'static str,
356 pkg_version: &'static str,
357 file: &'static str,
358 id: &'static str,
359 attribute: &'static str,
360 fallback: &'static str,
361 ) -> L10nMessageBuilder {
362 self.message(
363 LangFilePath {
364 pkg_name: Txt::from_static(pkg_name),
365 pkg_version: pkg_version.parse().unwrap(),
366 file: Txt::from_static(file),
367 },
368 Txt::from_static(id),
369 Txt::from_static(attribute),
370 Txt::from_static(fallback),
371 )
372 }
373
374 /// Gets a handle to the lang file resource.
375 ///
376 /// The resource will be loaded and stay in memory until all clones of the handle are dropped, this
377 /// can be used to pre-load resources so that localized messages find it immediately avoiding flashing
378 /// the fallback text in the UI.
379 ///
380 /// If the resource directory or file changes it is auto-reloaded, just like when a message variable
381 /// held on the resource does.
382 ///
383 /// # Params
384 ///
385 /// * `lang`: Language identifier.
386 /// * `file`: Name of the resource file, in the default directory layout the file is searched at `dir/{lang}/{file}.ftl`, if
387 /// empty the file is searched at `dir/{lang}/_.ftl`. Only a single file name is valid, no other path components allowed.
388 /// Note that the file can also be a full [`LangFilePath`] that includes dependency package info. Those files are searched in
389 /// `dir/{lang}/deps/{pkg-name}/{pkg-version}/{file}.ftl`.
390 ///
391 /// Panics if the file is invalid.
392 pub fn lang_resource(&self, lang: impl Into<Lang>, file: impl Into<LangFilePath>) -> LangResource {
393 L10N_SV.write().lang_resource(lang.into(), file.into())
394 }
395
396 /// Gets a handle to all resource files for the `lang` after they load.
397 ///
398 /// This awaits for the available langs to load, then collect an awaits for all lang files.
399 pub async fn wait_lang(&self, lang: impl Into<Lang>) -> LangResources {
400 let lang = lang.into();
401 let mut r = vec![];
402 for (file, _) in self.available_langs().get().get(&lang).into_iter().flatten() {
403 r.push(self.lang_resource(lang.clone(), file.clone()));
404 }
405 for h in &r {
406 h.wait().await;
407 }
408 LangResources(r)
409 }
410
411 /// Gets a handle to all resource files of the first lang in `langs` that is available and loaded.
412 ///
413 /// This awaits for the available langs to load, then collect an awaits for all lang files.
414 pub async fn wait_first(&self, langs: impl Into<Langs>) -> (Option<Lang>, LangResources) {
415 let langs = langs.into();
416
417 L10N.wait_available_langs().await;
418
419 let available = L10N.available_langs().get();
420 for lang in langs.0 {
421 if let Some(files) = available.get_exact(&lang) {
422 let mut r = Vec::with_capacity(files.len());
423 for file in files.keys() {
424 r.push(self.lang_resource(lang.clone(), file.clone()));
425 }
426 let handle = LangResources(r);
427 handle.wait().await;
428
429 return (Some(lang), handle);
430 }
431 }
432
433 (None, LangResources(vec![]))
434 }
435
436 /// Bind the command metadata to a message.
437 ///
438 /// This is automatically called by [`command!`] instances that set the metadata `l10n!: true` or `l10n!: "file"`.
439 ///
440 /// [`command!`]: zng_app::event::command!
441 pub fn bind_command_meta(
442 &self,
443 file: impl Into<LangFilePath>,
444 cmd: Command,
445 meta_name: impl Into<Txt>,
446 meta_value: CommandMetaVar<Txt>,
447 ) {
448 let msg = self.message(file, cmd.static_name(), meta_name, meta_value.get()).build();
449 meta_value.set_from(&msg);
450
451 // bind only holds a weak ref to `meta_value`` in `msg`
452 msg.bind(&meta_value).perm();
453 meta_value
454 .hook(move |_| {
455 // keep `msg` alive to it continues updating `meta_value`
456 let _keep = &msg;
457 true
458 })
459 .perm();
460 }
461}
462
463/// <span data-del-macro-root></span> Compile-time validated [`Lang`] value.
464///
465/// The language is parsed during compile and any errors are emitted as compile time errors.
466///
467/// # Syntax
468///
469/// The input can be a single a single string literal with `-` separators, or a single ident with `_` as the separators.
470///
471/// # Examples
472///
473/// ```
474/// # use zng_ext_l10n::lang;
475/// let en_us = lang!(en_US);
476/// let en = lang!(en);
477///
478/// assert!(en.matches(&en_us, true, false));
479/// assert_eq!(en_us, lang!("en-US"));
480/// ```
481#[macro_export]
482macro_rules! lang {
483 ($($tt:tt)+) => {
484 {
485 let lang: $crate::unic_langid::LanguageIdentifier = $crate::__lang! {
486 unic_langid { $crate::unic_langid }
487 lang { $($tt)+ }
488 };
489 $crate::Lang(lang)
490 }
491 }
492}
493
494/// Represents a localization data source.
495///
496/// See [`L10N.load`] for more details.
497///
498/// [`L10N.load`]: L10N::load
499pub trait L10nSource: Send + 'static {
500 /// Gets a read-only variable with all lang files that the source can provide.
501 fn available_langs(&mut self) -> Var<Arc<LangMap<HashMap<LangFilePath, PathBuf>>>>;
502 /// Gets a read-only variable that is the status of the [`available_langs`] value.
503 ///
504 /// [`available_langs`]: Self::available_langs
505 fn available_langs_status(&mut self) -> Var<LangResourceStatus>;
506
507 /// Gets a read-only variable that provides the fluent resource for the `lang` and `file` if available.
508 fn lang_resource(&mut self, lang: Lang, file: LangFilePath) -> Var<Option<ArcEq<fluent::FluentResource>>>;
509 /// Gets a read-only variable that is the status of the [`lang_resource`] value.
510 ///
511 /// [`lang_resource`]: Self::lang_resource
512 fn lang_resource_status(&mut self, lang: Lang, file: LangFilePath) -> Var<LangResourceStatus>;
513}
514
515fn from_unic_char_direction(d: unic_langid::CharacterDirection) -> LayoutDirection {
516 match d {
517 unic_langid::CharacterDirection::LTR => LayoutDirection::LTR,
518 unic_langid::CharacterDirection::RTL => LayoutDirection::RTL,
519 d => {
520 tracing::warn!("converted {d:?} to LTR");
521 LayoutDirection::LTR
522 }
523 }
524}