zng_ext_l10n/
types.rs

1use std::{borrow::Cow, collections::HashMap, fmt, mem, ops, path::PathBuf, sync::Arc};
2
3use fluent::types::FluentNumber;
4use once_cell::sync::Lazy;
5use semver::Version;
6use zng_ext_fs_watcher::WatcherReadStatus;
7use zng_layout::context::LayoutDirection;
8use zng_txt::{ToTxt, Txt};
9use zng_var::{ArcEq, IntoVar, Var, VarValue, const_var, context_var, impl_from_and_into_var};
10
11use crate::{L10N, lang, service::L10N_SV};
12
13/// Handle to multiple localization resources.
14#[derive(Clone, Debug)]
15pub struct LangResources(pub Vec<LangResource>);
16impl ops::Deref for LangResources {
17    type Target = Vec<LangResource>;
18
19    fn deref(&self) -> &Self::Target {
20        &self.0
21    }
22}
23impl ops::DerefMut for LangResources {
24    fn deref_mut(&mut self) -> &mut Self::Target {
25        &mut self.0
26    }
27}
28impl LangResources {
29    /// Wait for all the resources to load.
30    pub async fn wait(&self) {
31        for res in &self.0 {
32            res.wait().await;
33        }
34    }
35
36    /// Drop all handles without dropping the resource.
37    pub fn perm(self) {
38        for res in self.0 {
39            res.perm()
40        }
41    }
42}
43
44/// Handle to a localization resource.
45#[derive(Clone)]
46#[must_use = "resource can unload if dropped"]
47pub struct LangResource {
48    pub(super) res: Var<Option<ArcEq<fluent::FluentResource>>>,
49    pub(super) status: Var<LangResourceStatus>,
50}
51
52impl fmt::Debug for LangResource {
53    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
54        f.debug_struct("LangResource")
55            .field("status", &self.status.get())
56            .finish_non_exhaustive()
57    }
58}
59impl LangResource {
60    /// Read-only variable with the resource.
61    pub fn resource(&self) -> &Var<Option<ArcEq<fluent::FluentResource>>> {
62        &self.res
63    }
64
65    /// Read-only variable with the resource status.
66    pub fn status(&self) -> &Var<LangResourceStatus> {
67        &self.status
68    }
69
70    /// Drop the handle without unloading the resource.
71    pub fn perm(self) {
72        L10N_SV.write().push_perm_resource(self);
73    }
74
75    /// Await resource status to not be loading.
76    pub async fn wait(&self) {
77        while matches!(self.status.get(), LangResourceStatus::Loading) {
78            self.status.wait_update().await;
79        }
80    }
81}
82
83/// Status of a localization resource.
84#[derive(Clone, Debug)]
85pub enum LangResourceStatus {
86    /// Resource not available.
87    ///
88    /// This can change if the localization directory changes, or the file is created.
89    NotAvailable,
90    /// Resource is loading.
91    Loading,
92    /// Resource loaded ok.
93    Loaded,
94    /// Resource failed to load.
95    ///
96    /// This can be any IO or parse errors. If the resource if *not found* the status is set to
97    /// `NotAvailable`, not an error. Localization messages fallback on error just like they do
98    /// for not available.
99    Errors(StatusError),
100}
101impl fmt::Display for LangResourceStatus {
102    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
103        match self {
104            LangResourceStatus::NotAvailable => write!(f, "not available"),
105            LangResourceStatus::Loading => write!(f, "loading…"),
106            LangResourceStatus::Loaded => write!(f, "loaded"),
107            LangResourceStatus::Errors(e) => {
108                writeln!(f, "errors:")?;
109                for e in e {
110                    writeln!(f, "   {e}")?;
111                }
112                Ok(())
113            }
114        }
115    }
116}
117impl PartialEq for LangResourceStatus {
118    fn eq(&self, other: &Self) -> bool {
119        match (self, other) {
120            (Self::Errors(a), Self::Errors(b)) => a.is_empty() && b.is_empty(),
121            _ => core::mem::discriminant(self) == core::mem::discriminant(other),
122        }
123    }
124}
125impl Eq for LangResourceStatus {}
126impl WatcherReadStatus<StatusError> for LangResourceStatus {
127    fn idle() -> Self {
128        Self::Loaded
129    }
130
131    fn reading() -> Self {
132        Self::Loading
133    }
134
135    fn read_error(e: StatusError) -> Self {
136        Self::Errors(e)
137    }
138}
139impl WatcherReadStatus<LangResourceStatus> for LangResourceStatus {
140    fn idle() -> Self {
141        Self::Loaded
142    }
143
144    fn reading() -> Self {
145        Self::Loading
146    }
147
148    fn read_error(e: LangResourceStatus) -> Self {
149        e
150    }
151}
152
153type StatusError = Vec<Arc<dyn std::error::Error + Send + Sync>>;
154
155/// Localized message variable builder.
156///
157/// See [`L10N.message`] for more details.
158///
159/// [`L10N.message`]: L10N::message
160pub struct L10nMessageBuilder {
161    pub(super) file: LangFilePath,
162    pub(super) id: Txt,
163    pub(super) attribute: Txt,
164    pub(super) fallback: Txt,
165    pub(super) args: Vec<(Txt, Var<L10nArgument>)>,
166}
167impl L10nMessageBuilder {
168    /// Add a format arg variable.
169    pub fn arg(mut self, name: Txt, value: impl IntoVar<L10nArgument>) -> Self {
170        self.args.push((name, value.into_var()));
171        self
172    }
173    #[doc(hidden)]
174    pub fn l10n_arg(self, name: &'static str, value: Var<L10nArgument>) -> Self {
175        self.arg(Txt::from_static(name), value)
176    }
177
178    /// Build the message var for the given languages.
179    pub fn build_for(self, lang: impl Into<Langs>) -> Var<Txt> {
180        L10N_SV
181            .write()
182            .localized_message(lang.into(), self.file, self.id, self.attribute, self.fallback, self.args)
183    }
184
185    /// Build the message var for the contextual language.
186    pub fn build(self) -> Var<Txt> {
187        let Self {
188            file,
189            id,
190            attribute,
191            fallback,
192            args,
193        } = self;
194        LANG_VAR.flat_map(move |l| {
195            L10N_SV.write().localized_message(
196                l.clone(),
197                file.clone(),
198                id.clone(),
199                attribute.clone(),
200                fallback.clone(),
201                args.clone(),
202            )
203        })
204    }
205}
206
207/// Represents an argument value for a localization message.
208///
209/// See [`L10nMessageBuilder::arg`] for more details.
210#[derive(Clone, Debug, PartialEq)]
211pub enum L10nArgument {
212    /// String.
213    Txt(Txt),
214    /// Number, with optional style details.
215    Number(FluentNumber),
216}
217impl_from_and_into_var! {
218    fn from(txt: Txt) -> L10nArgument {
219        L10nArgument::Txt(txt)
220    }
221    fn from(txt: &'static str) -> L10nArgument {
222        L10nArgument::Txt(Txt::from_static(txt))
223    }
224    fn from(txt: String) -> L10nArgument {
225        L10nArgument::Txt(Txt::from(txt))
226    }
227    fn from(t: char) -> L10nArgument {
228        L10nArgument::Txt(Txt::from_char(t))
229    }
230    fn from(number: FluentNumber) -> L10nArgument {
231        L10nArgument::Number(number)
232    }
233    fn from(b: bool) -> L10nArgument {
234        b.to_txt().into()
235    }
236}
237macro_rules! impl_from_and_into_var_number {
238    ($($literal:tt),+) => {
239        impl_from_and_into_var! {
240            $(
241                fn from(number: $literal) -> L10nArgument {
242                    FluentNumber::from(number).into()
243                }
244            )+
245        }
246    }
247}
248impl_from_and_into_var_number! { u8, i8, u16, i16, u32, i32, u64, i64, u128, i128, usize, isize, f32, f64 }
249impl L10nArgument {
250    /// Borrow argument as a fluent value.
251    pub fn fluent_value(&self) -> fluent::FluentValue<'_> {
252        match self {
253            L10nArgument::Txt(t) => fluent::FluentValue::String(Cow::Borrowed(t.as_str())),
254            L10nArgument::Number(n) => fluent::FluentValue::Number(n.clone()),
255        }
256    }
257    /// Clone argument as a fluent value.
258    pub fn to_fluent_value(&self) -> fluent::FluentValue<'static> {
259        match self {
260            L10nArgument::Txt(t) => fluent::FluentValue::String(Cow::Owned(t.to_string())),
261            L10nArgument::Number(n) => fluent::FluentValue::Number(n.clone()),
262        }
263    }
264}
265
266#[doc(hidden)]
267pub struct L10nSpecialize<T>(pub Option<T>);
268#[doc(hidden)]
269pub trait IntoL10nVar {
270    fn to_l10n_var(&mut self) -> Var<L10nArgument>;
271}
272
273impl<T: Into<L10nArgument>> IntoL10nVar for L10nSpecialize<T> {
274    fn to_l10n_var(&mut self) -> Var<L10nArgument> {
275        const_var(self.0.take().unwrap().into())
276    }
277}
278impl<T: VarValue + Into<L10nArgument>> IntoL10nVar for &mut L10nSpecialize<Var<T>> {
279    fn to_l10n_var(&mut self) -> Var<L10nArgument> {
280        self.0.take().unwrap().map_into()
281    }
282}
283impl IntoL10nVar for &mut &mut L10nSpecialize<Var<L10nArgument>> {
284    fn to_l10n_var(&mut self) -> Var<L10nArgument> {
285        self.0.take().unwrap()
286    }
287}
288
289context_var! {
290    /// Language of text in a widget context.
291    ///
292    /// Is [`L10N.app_lang`] by default.
293    ///
294    /// [`L10N.app_lang`]: L10N::app_lang
295    pub static LANG_VAR: Langs = {
296        if zng_app::APP.extensions().contains::<crate::L10nManager>() {
297            L10N.app_lang()
298        } else {
299            tracing::warn!("LANG_VAR default not connected to L10N.app_lang, L10nManager extension is missing");
300            Langs::default().into_var()
301        }
302    };
303}
304
305/// Identifies the language, region and script of text.
306///
307/// Use the [`lang!`] macro to construct one, it does compile-time validation.
308///
309/// Use the [`unic_langid`] crate for more advanced operations such as runtime parsing and editing identifiers, this
310/// type is just an alias for the core struct of that crate.
311///
312/// [`unic_langid`]: https://docs.rs/unic-langid
313#[derive(PartialEq, Eq, Hash, Clone, Default, PartialOrd, Ord, serde::Serialize, serde::Deserialize)]
314#[serde(transparent)]
315pub struct Lang(pub unic_langid::LanguageIdentifier);
316impl Lang {
317    /// Returns character direction of the language.
318    pub fn direction(&self) -> LayoutDirection {
319        crate::from_unic_char_direction(self.0.character_direction())
320    }
321
322    /// Compares a language to another allowing for either side to use the missing fields as wildcards.
323    ///
324    /// This allows for matching between `en` (treated as `en-*-*-*`) and `en-US`.
325    pub fn matches(&self, other: &Self, self_as_range: bool, other_as_range: bool) -> bool {
326        self.0.matches(&other.0, self_as_range, other_as_range)
327    }
328}
329impl ops::Deref for Lang {
330    type Target = unic_langid::LanguageIdentifier;
331
332    fn deref(&self) -> &Self::Target {
333        &self.0
334    }
335}
336impl fmt::Debug for Lang {
337    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
338        write!(f, "{}", self.0)
339    }
340}
341impl fmt::Display for Lang {
342    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
343        write!(f, "{}", self.0)
344    }
345}
346impl std::str::FromStr for Lang {
347    type Err = unic_langid::LanguageIdentifierError;
348
349    fn from_str(s: &str) -> Result<Self, Self::Err> {
350        let s = s.trim();
351        if s.is_empty() {
352            return Ok(lang!(und));
353        }
354        unic_langid::LanguageIdentifier::from_str(s).map(Lang)
355    }
356}
357
358/// List of languages, in priority order.
359#[derive(Clone, PartialEq, Eq, Default, Hash, serde::Serialize, serde::Deserialize)]
360#[serde(transparent)]
361pub struct Langs(pub Vec<Lang>);
362impl fmt::Debug for Langs {
363    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
364        struct DisplayLangs<'a>(&'a [Lang]);
365        impl fmt::Debug for DisplayLangs<'_> {
366            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
367                f.debug_list().entries(self.0.iter()).finish()
368            }
369        }
370        if f.alternate() {
371            f.debug_tuple("Langs").field(&DisplayLangs(&self.0)).finish()
372        } else {
373            fmt::Debug::fmt(&DisplayLangs(&self.0), f)
374        }
375    }
376}
377impl Langs {
378    /// The first lang on the list or `und` if the list is empty.
379    pub fn best(&self) -> &Lang {
380        static NONE: Lazy<Lang> = Lazy::new(|| lang!(und));
381        self.first().unwrap_or(&NONE)
382    }
383}
384impl ops::Deref for Langs {
385    type Target = Vec<Lang>;
386
387    fn deref(&self) -> &Self::Target {
388        &self.0
389    }
390}
391impl ops::DerefMut for Langs {
392    fn deref_mut(&mut self) -> &mut Self::Target {
393        &mut self.0
394    }
395}
396impl_from_and_into_var! {
397    fn from(lang: Lang) -> Langs {
398        Langs(vec![lang])
399    }
400    fn from(lang: Option<Lang>) -> Langs {
401        Langs(lang.into_iter().collect())
402    }
403}
404impl fmt::Display for Langs {
405    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
406        let mut sep = "";
407        for l in self.iter() {
408            write!(f, "{sep}{l}")?;
409            sep = ", ";
410        }
411        Ok(())
412    }
413}
414impl std::str::FromStr for Langs {
415    type Err = unic_langid::LanguageIdentifierError;
416
417    fn from_str(s: &str) -> Result<Self, Self::Err> {
418        if s.trim().is_empty() {
419            return Ok(Langs(vec![]));
420        }
421        let mut r = Self(vec![]);
422        for lang in s.split(',') {
423            r.0.push(lang.trim().parse()?)
424        }
425        Ok(r)
426    }
427}
428
429/// Represents a map of [`Lang`] keys that can be partially matched.
430#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
431#[serde(transparent)]
432pub struct LangMap<V> {
433    inner: Vec<(Lang, V)>,
434}
435impl<V> Default for LangMap<V> {
436    fn default() -> Self {
437        Self { inner: Default::default() }
438    }
439}
440impl<V> LangMap<V> {
441    /// New empty default.
442    pub fn new() -> Self {
443        LangMap::default()
444    }
445
446    /// New empty with pre-allocated capacity.
447    pub fn with_capacity(capacity: usize) -> Self {
448        LangMap {
449            inner: Vec::with_capacity(capacity),
450        }
451    }
452
453    fn exact_i(&self, lang: &Lang) -> Option<usize> {
454        for (i, (key, _)) in self.inner.iter().enumerate() {
455            if key == lang {
456                return Some(i);
457            }
458        }
459        None
460    }
461
462    fn best_i(&self, lang: &Lang) -> Option<usize> {
463        let mut best = None;
464        let mut best_weight = 0;
465
466        for (i, (key, _)) in self.inner.iter().enumerate() {
467            if lang.matches(key, true, true) {
468                let mut weight = 1;
469                let mut eq = 0;
470
471                if key.language == lang.language {
472                    weight += 128;
473                    eq += 1;
474                }
475                if key.region == lang.region {
476                    weight += 40;
477                    eq += 1;
478                }
479                if key.script == lang.script {
480                    weight += 20;
481                    eq += 1;
482                }
483
484                if eq == 3 && lang.variants().zip(key.variants()).all(|(a, b)| a == b) {
485                    return Some(i);
486                }
487
488                if best_weight < weight {
489                    best_weight = weight;
490                    best = Some(i);
491                }
492            }
493        }
494
495        best
496    }
497
498    /// Returns the best match to `lang` currently in the map.
499    pub fn best_match(&self, lang: &Lang) -> Option<&Lang> {
500        if let Some(i) = self.best_i(lang) {
501            Some(&self.inner[i].0)
502        } else {
503            None
504        }
505    }
506
507    /// Returns the best match for `lang`.
508    pub fn get(&self, lang: &Lang) -> Option<&V> {
509        if let Some(i) = self.best_i(lang) {
510            Some(&self.inner[i].1)
511        } else {
512            None
513        }
514    }
515
516    /// Returns the exact match for `lang`.
517    pub fn get_exact(&self, lang: &Lang) -> Option<&V> {
518        if let Some(i) = self.exact_i(lang) {
519            Some(&self.inner[i].1)
520        } else {
521            None
522        }
523    }
524
525    /// Returns the best match for `lang`.
526    pub fn get_mut(&mut self, lang: &Lang) -> Option<&mut V> {
527        if let Some(i) = self.best_i(lang) {
528            Some(&mut self.inner[i].1)
529        } else {
530            None
531        }
532    }
533
534    /// Returns the exact match for `lang`.
535    pub fn get_exact_mut(&mut self, lang: &Lang) -> Option<&mut V> {
536        if let Some(i) = self.exact_i(lang) {
537            Some(&mut self.inner[i].1)
538        } else {
539            None
540        }
541    }
542
543    /// Returns the current value or insert `new` and return a reference to it.
544    pub fn get_exact_or_insert(&mut self, lang: Lang, new: impl FnOnce() -> V) -> &mut V {
545        if let Some(i) = self.exact_i(&lang) {
546            return &mut self.inner[i].1;
547        }
548        let i = self.inner.len();
549        self.inner.push((lang, new()));
550        &mut self.inner[i].1
551    }
552
553    /// Insert the value with the exact match of `lang`.
554    ///
555    /// Returns the previous exact match.
556    pub fn insert(&mut self, lang: Lang, value: V) -> Option<V> {
557        if let Some(i) = self.exact_i(&lang) {
558            Some(mem::replace(&mut self.inner[i].1, value))
559        } else {
560            self.inner.push((lang, value));
561            None
562        }
563    }
564
565    /// Remove the exact match of `lang`.
566    pub fn remove(&mut self, lang: &Lang) -> Option<V> {
567        if let Some(i) = self.exact_i(lang) {
568            Some(self.inner.swap_remove(i).1)
569        } else {
570            None
571        }
572    }
573
574    /// Remove all exact and partial matches of `lang`.
575    ///
576    /// Returns a count of items removed.
577    pub fn remove_all(&mut self, lang: &Lang) -> usize {
578        let mut count = 0;
579        self.inner.retain(|(key, _)| {
580            let rmv = lang.matches(key, true, false);
581            if rmv {
582                count += 1
583            }
584            !rmv
585        });
586        count
587    }
588
589    /// Remove the last inserted lang.
590    pub fn pop(&mut self) -> Option<(Lang, V)> {
591        self.inner.pop()
592    }
593
594    /// Returns if the map is empty.
595    pub fn is_empty(&self) -> bool {
596        self.inner.is_empty()
597    }
598
599    /// Returns the number of languages in the map.
600    pub fn len(&self) -> usize {
601        self.inner.len()
602    }
603
604    /// Remove all entries.
605    pub fn clear(&mut self) {
606        self.inner.clear()
607    }
608
609    /// Iterator over lang keys.
610    pub fn keys(&self) -> impl std::iter::ExactSizeIterator<Item = &Lang> {
611        self.inner.iter().map(|(k, _)| k)
612    }
613
614    /// Iterator over values.
615    pub fn values(&self) -> impl std::iter::ExactSizeIterator<Item = &V> {
616        self.inner.iter().map(|(_, v)| v)
617    }
618
619    /// Iterator over values.
620    pub fn values_mut(&mut self) -> impl std::iter::ExactSizeIterator<Item = &mut V> {
621        self.inner.iter_mut().map(|(_, v)| v)
622    }
623
624    /// Into iterator of values.
625    pub fn into_values(self) -> impl std::iter::ExactSizeIterator<Item = V> {
626        self.inner.into_iter().map(|(_, v)| v)
627    }
628
629    /// Iterate over key-value pairs.
630    pub fn iter(&self) -> impl std::iter::ExactSizeIterator<Item = (&Lang, &V)> {
631        self.inner.iter().map(|(k, v)| (k, v))
632    }
633
634    /// Iterate over key-value pairs with mutable values.
635    pub fn iter_mut(&mut self) -> impl std::iter::ExactSizeIterator<Item = (&Lang, &mut V)> {
636        self.inner.iter_mut().map(|(k, v)| (&*k, v))
637    }
638}
639impl<V> LangMap<HashMap<LangFilePath, V>> {
640    /// Returns the match for `lang` and `file`.
641    pub fn get_file(&self, lang: &Lang, file: &LangFilePath) -> Option<&V> {
642        let files = self.get(lang)?;
643        if let Some(exact) = files.get(file) {
644            return Some(exact);
645        }
646        Self::best_file(files, file).map(|(_, v)| v)
647    }
648
649    /// Returns the best match to `lang` and `file` currently in the map.
650    pub fn best_file_match(&self, lang: &Lang, file: &LangFilePath) -> Option<&LangFilePath> {
651        let files = self.get(lang)?;
652        if let Some((exact, _)) = files.get_key_value(file) {
653            return Some(exact);
654        }
655        Self::best_file(files, file).map(|(k, _)| k)
656    }
657
658    fn best_file<'a>(files: &'a HashMap<LangFilePath, V>, file: &LangFilePath) -> Option<(&'a LangFilePath, &'a V)> {
659        let mut best = None;
660        let mut best_dist = u64::MAX;
661        for (k, v) in files {
662            if let Some(d) = k.matches(file)
663                && d < best_dist
664            {
665                best = Some((k, v));
666                best_dist = d;
667            }
668        }
669        best
670    }
671}
672impl<V> IntoIterator for LangMap<V> {
673    type Item = (Lang, V);
674
675    type IntoIter = std::vec::IntoIter<(Lang, V)>;
676
677    fn into_iter(self) -> Self::IntoIter {
678        self.inner.into_iter()
679    }
680}
681impl<V: PartialEq> PartialEq for LangMap<V> {
682    fn eq(&self, other: &Self) -> bool {
683        if self.len() != other.len() {
684            return false;
685        }
686        for (k, v) in &self.inner {
687            if other.get_exact(k) != Some(v) {
688                return false;
689            }
690        }
691        true
692    }
693}
694impl<V: Eq> Eq for LangMap<V> {}
695
696/// Errors found parsing a fluent resource file.
697#[derive(Clone, Debug)]
698pub struct FluentParserErrors(pub Vec<fluent_syntax::parser::ParserError>);
699impl fmt::Display for FluentParserErrors {
700    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
701        let mut sep = "";
702        for e in &self.0 {
703            write!(f, "{sep}{e}")?;
704            sep = "\n";
705        }
706        Ok(())
707    }
708}
709impl std::error::Error for FluentParserErrors {
710    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
711        if self.0.len() == 1 { Some(&self.0[0]) } else { None }
712    }
713}
714
715/// Localization resource file path in the localization directory.
716///
717/// In the default directory layout, localization dependencies are collected using `cargo zng l10n`
718/// and copied to `l10n/{lang}/deps/{name}/{version}/`, and localization for the app ([`is_current_app`])
719/// is placed in `l10n/{lang}/`.
720///
721/// [`is_current_app`]: Self::is_current_app
722#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
723#[non_exhaustive]
724pub struct LangFilePath {
725    /// Package name.
726    pub pkg_name: Txt,
727    /// Package version.
728    pub pkg_version: Version,
729    /// The localization file name, without extension.
730    pub file: Txt,
731}
732impl Ord for LangFilePath {
733    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
734        let self_pkg = self.actual_pkg_data();
735        let other_pkg = other.actual_pkg_data();
736        match self_pkg.0.cmp(other_pkg.0) {
737            core::cmp::Ordering::Equal => {}
738            ord => return ord,
739        }
740        match self_pkg.1.cmp(other_pkg.1) {
741            core::cmp::Ordering::Equal => {}
742            ord => return ord,
743        }
744        self.file().cmp(&other.file())
745    }
746}
747impl PartialOrd for LangFilePath {
748    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
749        Some(self.cmp(other))
750    }
751}
752impl std::hash::Hash for LangFilePath {
753    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
754        self.actual_pkg_data().hash(state);
755        self.file().hash(state);
756    }
757}
758impl Eq for LangFilePath {}
759impl PartialEq for LangFilePath {
760    fn eq(&self, other: &Self) -> bool {
761        self.actual_pkg_data() == other.actual_pkg_data() && self.file() == other.file()
762    }
763}
764impl LangFilePath {
765    /// New from package name, version and file.
766    pub fn new(pkg_name: impl Into<Txt>, pkg_version: Version, file: impl Into<Txt>) -> Self {
767        let r = Self {
768            pkg_name: pkg_name.into(),
769            pkg_version,
770            file: file.into(),
771        };
772        // these non-standard names are matched on fallback, but they can cause duplicate caching
773        debug_assert!(
774            r.file
775                .rsplit_once('.')
776                .map(|(_, ext)| !ext.eq_ignore_ascii_case("ftl"))
777                .unwrap_or(true),
778            "file `{}` must not have extension",
779            r.file
780        );
781        debug_assert!(r.file != "_", "file `_` should be an empty string");
782        r
783    }
784
785    /// Gets a file in the current app.
786    ///
787    /// This value indicates that the localization resources are directly on the `l10n/{lang?}/` directories, not
788    /// in the dependencies directories.
789    ///
790    /// See [`zng_env::about()`] for more details.
791    pub fn current_app(file: impl Into<Txt>) -> LangFilePath {
792        let about = zng_env::about();
793        Self::new(about.pkg_name.clone(), about.version.clone(), file.into())
794    }
795
796    /// Gets if this file is in the [`current_app`] resources, or the `pkg_name` is empty or the `pkg_version.pre` is `#.#.#-local`.
797    ///
798    /// [`current_app`]: Self::current_app
799    pub fn is_current_app(&self) -> bool {
800        self.is_current_app_no_check() || {
801            let about = zng_env::about();
802            self.pkg_name == about.pkg_name && self.pkg_version == about.version
803        }
804    }
805
806    fn is_current_app_no_check(&self) -> bool {
807        self.pkg_name.is_empty() || self.pkg_version.pre.as_str() == "local"
808    }
809
810    fn actual_pkg_data(&self) -> (&Txt, &Version) {
811        if self.is_current_app_no_check() {
812            let about = zng_env::about();
813            (&about.pkg_name, &about.version)
814        } else {
815            (&self.pkg_name, &self.pkg_version)
816        }
817    }
818
819    /// Gets the normalized package name.
820    ///
821    /// This is the app package name if [`is_current_app`], otherwise is just the `pkg_name` value.
822    ///
823    /// [`is_current_app`]: Self::is_current_app
824    pub fn pkg_name(&self) -> Txt {
825        self.actual_pkg_data().0.clone()
826    }
827
828    /// Gets the normalized package version.
829    ///
830    /// This is the app version if [`is_current_app`], otherwise is just the `pkg_version` value.
831    ///
832    /// [`is_current_app`]: Self::is_current_app
833    pub fn pkg_version(&self) -> Version {
834        self.actual_pkg_data().1.clone()
835    }
836
837    /// Gets the normalized file name.
838    ///
839    /// This `"_"` for empty file or the file.
840    pub fn file(&self) -> Txt {
841        if self.file.is_empty() {
842            Txt::from_char('_')
843        } else {
844            self.file.clone()
845        }
846    }
847
848    /// Get the file path, relative to the localization dir.
849    ///
850    /// * Empty file name is the same as `_`.
851    /// * If package [`is_current_app`] gets `{lang}/{file}.ftl`.
852    /// * Else if is another package gets `{lang}/deps/{pkg_name}/{pkg_version}/{file}.ftl`.
853    ///
854    /// [`is_current_app`]: Self::is_current_app
855    pub fn to_path(&self, lang: &Lang) -> PathBuf {
856        let mut file = self.file.as_str();
857        if file.is_empty() {
858            file = "_";
859        }
860        if self.is_current_app() {
861            format!("{lang}/{file}.ftl")
862        } else {
863            format!("{lang}/deps/{}/{}/{file}.ftl", self.pkg_name, self.pkg_version)
864        }
865        .into()
866    }
867
868    /// Gets a value that indicates if the resources represented by `self` can be used for `search`.
869    ///
870    /// The number indicates the quality of the match:
871    ///
872    /// * `0` is an exact match.
873    /// * `b1` is a match with only version `build` differences.
874    /// * `b10` is a match with only version `pre` differences.
875    /// * `(0..u16::MAX) << 16` is a match with only `patch` differences and the absolute distance.
876    /// * `(0..u16::MAX) << 16 * 2` is a match with `minor` differences and the absolute distance.
877    /// * `(0..u16::MAX) << 16 * 3` is a match with `major` differences and the absolute distance.
878    /// * `None`` is a `pkg_name` mismatch.
879    pub fn matches(&self, search: &Self) -> Option<u64> {
880        let (self_name, self_version) = self.actual_pkg_data();
881        let (search_name, search_version) = search.actual_pkg_data();
882
883        if self_name != search_name {
884            return None;
885        }
886
887        if self.file != search.file {
888            let file_a = self.file.rsplit_once('.').map(|t| t.0).unwrap_or(self.file.as_str());
889            let file_b = search.file.rsplit_once('.').map(|t| t.0).unwrap_or(search.file.as_str());
890            if file_a != file_b {
891                let is_empty_a = file_a == "_" || file_a.is_empty();
892                let is_empty_b = file_b == "_" || file_b.is_empty();
893                if !(is_empty_a && is_empty_b) {
894                    return None;
895                }
896            }
897            tracing::warn!(
898                "fallback matching `{}` with `{}`, file was not expected to have extension",
899                self.file,
900                search.file
901            )
902        }
903
904        fn dist(a: u64, b: u64, shift: u64) -> u64 {
905            let (l, s) = match a.cmp(&b) {
906                std::cmp::Ordering::Equal => return 0,
907                std::cmp::Ordering::Less => (b, a),
908                std::cmp::Ordering::Greater => (a, b),
909            };
910
911            (l - s).min(u16::MAX as u64) << (16 * shift)
912        }
913
914        let mut d = 0;
915        if self_version.build != search_version.build {
916            d = 1;
917        }
918        if self_version.pre != search_version.pre {
919            d |= 0b10;
920        }
921
922        d |= dist(self_version.patch, search_version.patch, 1);
923        d |= dist(self_version.minor, search_version.minor, 2);
924        d |= dist(self_version.major, search_version.major, 3);
925
926        Some(d)
927    }
928}
929impl_from_and_into_var! {
930    fn from(file: Txt) -> LangFilePath {
931        LangFilePath::current_app(file)
932    }
933
934    fn from(file: &'static str) -> LangFilePath {
935        LangFilePath::current_app(file)
936    }
937
938    fn from(file: String) -> LangFilePath {
939        LangFilePath::current_app(file)
940    }
941}
942
943#[cfg(test)]
944mod tests {
945    use super::*;
946
947    #[test]
948    fn file_matches() {
949        fn check(a: &str, b: &str, c: &str) {
950            let ap = LangFilePath::new("name", a.parse().unwrap(), "file");
951            let bp = LangFilePath::new("name", b.parse().unwrap(), "file");
952            let cp = LangFilePath::new("name", c.parse().unwrap(), "file");
953
954            let ab = ap.matches(&bp);
955            let ac = ap.matches(&cp);
956
957            assert!(ab < ac, "expected {a}.matches({b}) < {a}.matches({c})")
958        }
959
960        check("0.0.0", "0.0.1", "0.1.0");
961        check("0.0.1", "0.1.0", "1.0.0");
962        check("0.0.0-pre", "0.0.0-pre+build", "0.0.0-other+build");
963        check("0.0.0+build", "0.0.0+build", "0.0.0+other");
964        check("0.0.1", "0.0.2", "0.0.3");
965        check("0.1.0", "0.2.0", "0.3.0");
966        check("1.0.0", "2.0.0", "3.0.0");
967        check("1.0.0", "1.1.0", "2.0.0");
968    }
969
970    #[test]
971    fn file_name_mismatches() {
972        let ap = LangFilePath::new("name", "1.0.0".parse().unwrap(), "file-a");
973        let bp = LangFilePath::new("name", "1.0.0".parse().unwrap(), "file-b");
974
975        assert!(ap.matches(&bp).is_none());
976    }
977}