Skip to main content

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