Skip to main content

rosin_core/
localization.rs

1//! Localization primitives built on top of [Project Fluent](https://projectfluent.org).
2//!
3//! This module provides two main types:
4//!
5//! - [`TranslationMap`]: a threadsafe collection of [`TranslationFile`]s keyed by locale.
6//! - [`LocalizedString`]: a lazily resolved string with named arguments.
7//!
8//! ## Basic workflow
9//!
10//! 1. Load one or more Fluent translation files (`.ftl`) into a [`TranslationMap`].
11//! 2. Set the active locale on the map.
12//! 3. Build [`LocalizedString`] values with a message key and optional arguments.
13//! 4. Pass [`LocalizedString`] to widgets that accept `impl Into<UIString>`
14//!    or call [`LocalizedString::resolve`] to obtain the localized string for the current locale.
15//!
16//! ```ignore
17//! let map = TranslationMap::new(langid!("en-US"))
18//!     .add_translation(TranslationFile::from_file(vec![langid!("en-US")], "en-US.ftl")?)
19//!     .add_translation(TranslationFile::from_file(vec![langid!("es-ES")], "es-ES.ftl")?);
20//!
21//! map.set_current_locale(langid!("es-ES"));
22//!
23//! let greeting = LocalizedStringBuilder::new("greeting")
24//!     .arg("name", "Alice")
25//!     .build();
26//!
27//! let text = greeting.resolve(&map);
28//! assert_eq!(text, "Hola, Alice!");
29//! ```
30//!
31//! ## Locale resolution
32//!
33//! `TranslationMap` resolves using the active locale exactly as set via [`TranslationMap::set_current_locale`].
34//! This crate does not currently perform locale fallback/negotiation.
35//!
36//! If no translation file exists for the active locale, string resolution falls back to the placeholder (if set)
37//! or the message key.
38//!
39//! ## Caching
40//!
41//! [`LocalizedString`] caches its resolved output for the current locale.
42//! The cache is invalidated and recomputed when any of the following change:
43//!
44//! - the active locale
45//! - the set of loaded translations (including reloads)
46//! - any reactive arguments used by the string
47//!
48//! ## Missing messages and formatting errors
49//!
50//! When resolving a [`LocalizedString`]:
51//!
52//! - If the message key is not found, or the message exists but cannot be formatted,
53//!   resolution falls back to the placeholder (if set), or the message key itself.
54//! - If a formatting function (like `NUMBER` / `DATETIME`) cannot handle an argument,
55//!   it returns the original value unchanged.
56//!
57//! ## Arguments
58//!
59//! Arguments set with [`LocalizedStringBuilder::arg`] become Fluent named arguments:
60//!
61//! ```ftl
62//! greeting = Hello, { $name }!
63//! items = You have { NUMBER($count) } items.
64//! ```
65//!
66//! Supported argument values include:
67//!
68//! - strings (`&'static str`, `String`, `Cow<'static, str>`)
69//! - numbers (`f64`)
70//! - reactive variables (`WeakVar<T>`)
71//! - `Date`, `Time`, `OffsetDateTime` *(requires the `icu` feature)*
72//!
73//! ## Custom formatting functions
74//!
75//! Translation files can call custom functions provided by this crate:
76//!
77//! - `NUMBER(...)` for numeric formatting
78//! - `DATETIME(...)` for date/time formatting *(requires the `icu` feature)*
79//!
80//! Functions are used in Fluent files like this:
81//!
82//! ```ftl
83//! score = Score: { NUMBER($score) }
84//! updated = Updated: { DATETIME($when, dateStyle: "medium", timeStyle: "short") }
85//! ```
86//!
87//! Named options are passed as `key: value` pairs:
88//!
89//! ```ftl
90//! users = Users: { NUMBER($count, useGrouping: "false") }
91//! ```
92//!
93//! ### `NUMBER(...)`
94//!
95//! The default `NUMBER` function formats numeric values and supports
96//! a small set of options that mostly mirror common Intl/ECMA-402 names.
97//!
98//! Options:
99//!
100//! - `useGrouping`
101//!   - `"false"` disables grouping separators.
102//!
103//! - `minimumIntegerDigits`
104//!   - Pads the integer portion with leading zeroes.
105//!
106//! - `minimumFractionDigits`
107//!   - Pads the fractional portion with trailing zeroes.
108//!
109//! - `maximumFractionDigits`
110//!   - Rounds to at most this many fractional digits.
111//!
112//! - `minimumSignificantDigits` / `maximumSignificantDigits`
113//!   - Significant digit formatting.
114//!   - If either significant digit option is present, it takes precedence over fraction digit options.
115//!
116//! #### Default formatter *(no `icu` feature)*
117//!
118//! Without `icu`, `NUMBER(...)` is implemented with a simplified formatter.
119//!
120//! - Decimal separator is always `.`
121//! - Grouping uses `,` when enabled
122//! - Output is ASCII (not locale-aware)
123//! - Supports all `NUMBER(...)` options listed above
124//!
125//! Examples:
126//!
127//! ```ftl
128//! # Basic
129//! n-basic = { NUMBER($n) }
130//!
131//! # Force exactly 2 decimals
132//! n-2dp = { NUMBER($n, minimumFractionDigits: 2, maximumFractionDigits: 2) }
133//! ```
134//!
135//! #### What `icu` adds to `NUMBER(...)`
136//!
137//! Enabling the `icu` feature switches `NUMBER(...)` to ICU-backed formatting and adds true locale-aware output:
138//!
139//! - Locale-appropriate decimal separator and grouping separator
140//! - Locale-appropriate grouping patterns
141//! - `useGrouping: "always"` support (forces grouping where ICU supports it)
142//! - Uses locale-appropriate digits (not just 0–9). For example, in `ar-EG`, `{ NUMBER(12345) }` may render as `١٢٣٤٥`.
143//!
144//! **Note:** ICU currency formatting is still considered experimental upstream, so currency formatting
145//! is not supported yet.
146//!
147//! ### `DATETIME(...)` *(requires `icu`)*
148//!
149//! The `DATETIME` function formats date/time values using ICU locale rules.
150//! It is only available when the `icu` feature is enabled.
151//!
152//! Supported inputs are:
153//!
154//! - `time::Date`
155//! - `time::Time`
156//! - `time::OffsetDateTime`
157//! - A string in ISO-8601 date-time form: `YYYY-MM-DDTHH:MM:SS`
158//!
159//! Options:
160//!
161//! - `dateStyle`: `"long" | "medium" | "short"`
162//! - `timeStyle`: `"long" | "medium" | "short"`
163//!
164//! What `"long" | "medium" | "short"` mean:
165//!
166//! These map to ICU "length" styles. The exact output is locale-specific, but the intent is:
167//!
168//! - `dateStyle: "long"`
169//!   - A more verbose, more human-friendly date format.
170//!   - Typically uses month names and more words (for example `January 15, 2026` in `en-US`).
171//!
172//! - `dateStyle: "medium"`
173//!   - A compact but still readable date format.
174//!   - Often uses abbreviated month names (for example `Jan 15, 2026` in `en-US`).
175//!
176//! - `dateStyle: "short"`
177//!   - The most compact date format.
178//!   - Typically numeric (for example `1/15/26` in `en-US`).
179//!
180//! - `timeStyle: "short"`
181//!   - Hours + minutes only.
182//!   - Example: `9:30 AM` (seconds omitted).
183//!
184//! - `timeStyle: "medium"` / `timeStyle: "long"`
185//!   - Includes seconds.
186//!   - Example: `9:30:00 AM`.
187//!
188//!   **Note:** This crate currently does not include time zone names/offsets in the output,
189//!   so `"long"` time is usually the same as `"medium"`.
190//!
191//! Behavior:
192//!
193//! - `dateStyle` only → formats date (or the date part of a datetime)
194//! - `timeStyle` only → formats time (or the time part of a datetime)
195//! - both → formats full date + time *(requires a datetime input)*
196//! - neither → defaults to `dateStyle: "medium"`
197//!
198//! **Note:** Time zone names and offsets are not currently formatted. `OffsetDateTime` values are formatted
199//! using only their date and time components.
200//!
201//! Examples:
202//!
203//! ```ftl
204//! # Date only
205//! dt-date = { DATETIME($when, dateStyle: "long") }
206//!
207//! # Time only
208//! dt-time = { DATETIME($when, timeStyle: "short") }
209//!
210//! # Date + time (OffsetDateTime recommended)
211//! dt-both = { DATETIME($when, dateStyle: "medium", timeStyle: "short") }
212//! ```
213//!
214//! ### `icu` feature
215//!
216//! When the `icu` feature is enabled, the crate uses the `icu` and `time` crates to provide:
217//!
218//! - More complete locale-aware number formatting for `NUMBER(...)`
219//! - Date and time formatting via `DATETIME(...)`
220//!
221//! With `icu` enabled, this crate also re-exports:
222//!
223//! - `time::Date`
224//! - `time::Time`
225//! - `time::OffsetDateTime`
226//!
227//! so you can pass them as arguments without adding a direct dependency on the `time` crate.
228//!
229//! ### `serde` feature
230//!
231//! When the `serde` feature is enabled, [`LocalizedString`] can be serialized and deserialized.
232//!
233//! ### Security
234//!
235//! Currently, the fluent crate can be coerced into panicking, so it's not recommended to use untrusted localization files.
236
237use std::{borrow::Cow, collections::HashMap, fmt, fs, path::Path, sync::Arc, time::Instant};
238
239use fluent_bundle::{FluentArgs, FluentResource, FluentValue, concurrent::FluentBundle};
240use log::error;
241use parking_lot::{MappedRwLockReadGuard, RwLock, RwLockReadGuard, RwLockWriteGuard};
242use sys_locale::get_locale;
243use unic_langid::LanguageIdentifier;
244
245#[cfg(feature = "icu")]
246use fixed_decimal::{Decimal, FloatPrecision};
247#[cfg(feature = "icu")]
248use fluent_bundle::types::FluentType;
249#[cfg(feature = "icu")]
250use icu::{
251    calendar::Iso,
252    datetime::{
253        DateTimeFormatter,
254        fieldsets::{
255            self,
256            enums::{DateAndTimeFieldSet, DateFieldSet, TimeFieldSet},
257        },
258        options::Length,
259    },
260    decimal::{
261        DecimalFormatter,
262        options::{DecimalFormatterOptions, GroupingStrategy},
263    },
264};
265#[cfg(feature = "icu")]
266use time::{Date, OffsetDateTime, Time};
267
268#[cfg(feature = "icu")]
269use std::str::FromStr;
270
271use crate::{
272    prelude::*,
273    reactive::{VarKey, VarReadGuard},
274    util::ResourceInfo,
275};
276
277struct TranslationMapInner {
278    last_loaded: Instant,
279    current_locale: LanguageIdentifier,
280    translations: HashMap<LanguageIdentifier, TranslationFile>,
281}
282
283/// The global set of all translations for an application.
284///
285/// It maps the current locale to a [`TranslationFile`].
286///
287/// The contents are stored in an [`Arc<T>`] so it's cheap to clone.
288#[derive(Clone)]
289pub struct TranslationMap {
290    // we need both change tracking and shared ownership
291    inner: Arc<Var<TranslationMapInner>>,
292}
293
294impl Default for TranslationMap {
295    /// Creates a TranslationMap with the system's locale. Defaults to "en-US" if getting the system's locale fails.
296    fn default() -> Self {
297        Self::new(get_locale().map_or_else(|| unic_langid::langid!("en-US"), |locale| locale.parse().unwrap_or(unic_langid::langid!("en-US"))))
298    }
299}
300
301impl TranslationMap {
302    /// Creates a new TranslationMap.
303    pub fn new(current_locale: LanguageIdentifier) -> Self {
304        Self {
305            inner: Arc::new(Var::new(TranslationMapInner {
306                last_loaded: Instant::now(),
307                current_locale,
308                translations: HashMap::new(),
309            })),
310        }
311    }
312
313    /// Adds a new translation file to the map so it can be used by the application.
314    pub fn add_translation(self, file: TranslationFile) -> Self {
315        let mut guard = self.inner.write();
316        // We can safely access index 0 because constructors guarantee locales is not empty
317        guard.translations.insert(file.bundle.locales[0].clone(), file);
318        guard.last_loaded = Instant::now();
319        drop(guard);
320        self
321    }
322
323    /// Returns a [`FluentBundle`] for the requested locale, if available.
324    pub fn get_bundle(&self, locale: &LanguageIdentifier) -> Option<VarReadGuard<'_, FluentBundle<FluentResource>>> {
325        VarReadGuard::try_map(self.inner.read(), |inner| inner.translations.get(locale).map(|f| &f.bundle)).ok()
326    }
327
328    /// Returns the locale that will be used to resolve strings.
329    pub fn get_current_locale(&self) -> VarReadGuard<'_, LanguageIdentifier> {
330        VarReadGuard::map(self.inner.read(), |inner| &inner.current_locale)
331    }
332
333    /// Sets the locale that will be used to resolve strings.
334    pub fn set_current_locale(&mut self, locale: LanguageIdentifier) {
335        self.inner.write().current_locale = locale;
336    }
337
338    /// Reloads any files that have been modified since they were last loaded.
339    ///
340    /// Returns `true` if any of the files were successfully reloaded.
341    ///
342    /// ## Errors
343    ///
344    /// The method will return a boolean irrelevant of errors, but in case of errors,
345    /// the `Err` variant will also contain a Vec of any io errors encountered.
346    pub fn reload(&mut self) -> Result<bool, (bool, Vec<std::io::Error>)> {
347        let mut guard = self.inner.write();
348
349        let mut reloaded = false;
350        let mut errors = Vec::new();
351
352        for file in guard.translations.values_mut() {
353            match file.reload() {
354                Ok(did_load) => reloaded |= did_load,
355                Err(error) => errors.push(error),
356            }
357        }
358
359        if reloaded {
360            guard.last_loaded = Instant::now();
361        } else {
362            // nothing changed on disk, don't mark as changed
363            guard.cancel_change();
364        }
365
366        if !errors.is_empty() { Err((reloaded, errors)) } else { Ok(reloaded) }
367    }
368}
369
370/// A collection of localization messages for a single locale.
371///
372/// See <https://projectfluent.org> for a description of the syntax of Fluent files.
373pub struct TranslationFile {
374    info: Option<ResourceInfo>,
375    bundle: FluentBundle<FluentResource>,
376}
377
378impl TranslationFile {
379    /// Loads translations from a string. The first element in `locales` should be the language this file represents, and will be used to
380    /// determine the correct plural rules for this file. You can optionally provide extra languages in the list; they will be used as
381    /// fallback date and time formatters if a formatter for the primary language is unavailable.
382    pub fn from_str(locales: Vec<LanguageIdentifier>, text: &str) -> Result<Self, std::io::Error> {
383        let bundle = Self::make_bundle(locales, text.to_string())?;
384        Ok(Self { info: None, bundle })
385    }
386
387    /// Loads translations from a file. The first element in `locales` should be the language this file represents, and will be used to
388    /// determine the correct plural rules for this file. You can optionally provide extra languages in the list; they will be used as
389    /// fallback date and time formatters if a formatter for the primary language is unavailable.
390    pub fn from_file(locales: Vec<LanguageIdentifier>, path: impl AsRef<Path>) -> Result<TranslationFile, std::io::Error> {
391        let path_buf = path.as_ref().canonicalize()?;
392        let text = fs::read_to_string(&path_buf)?;
393        let bundle = Self::make_bundle(locales, text)?;
394        let info = Some(ResourceInfo {
395            last_modified: fs::metadata(&path_buf)?.modified()?,
396            path: path_buf.clone(),
397        });
398
399        Ok(Self { info, bundle })
400    }
401
402    pub(crate) fn reload(&mut self) -> Result<bool, std::io::Error> {
403        if let Some(ref mut resource_info) = self.info {
404            let current_modified_time = fs::metadata(&resource_info.path)?.modified()?;
405
406            if current_modified_time > resource_info.last_modified {
407                // File has been modified, reload it
408                let text = fs::read_to_string(&resource_info.path)?;
409                self.bundle = Self::make_bundle(self.bundle.locales.clone(), text)?;
410
411                resource_info.last_modified = current_modified_time;
412
413                return Ok(true);
414            }
415        }
416
417        Ok(false)
418    }
419
420    /// Shared logic for creating a FluentBundle from text.
421    fn make_bundle(locales: Vec<LanguageIdentifier>, text: String) -> Result<FluentBundle<FluentResource>, std::io::Error> {
422        if locales.is_empty() {
423            return Err(std::io::Error::new(std::io::ErrorKind::InvalidInput, "Locales list cannot be empty"));
424        }
425
426        let resource = match FluentResource::try_new(text) {
427            Ok(res) => res,
428            Err((res, error_list)) => {
429                for error in error_list {
430                    error!("{error}");
431                }
432                res
433            }
434        };
435        let mut bundle = FluentBundle::new_concurrent(locales.clone());
436
437        Self::add_custom_formatters(&mut bundle, &locales);
438
439        if let Err(error_list) = bundle.add_resource(resource) {
440            for error in error_list {
441                error!("{error}");
442            }
443        }
444        Ok(bundle)
445    }
446
447    #[cfg(not(feature = "icu"))]
448    fn add_custom_formatters(bundle: &mut FluentBundle<FluentResource>, _: &[LanguageIdentifier]) {
449        let _ = bundle.add_function("NUMBER", |args, named_args| {
450            let value = match args.first() {
451                Some(FluentValue::Number(n)) => n.value,
452                Some(v) => return v.clone(),
453                None => return FluentValue::Error,
454            };
455
456            // If we can't handle it as a number, return it unchanged.
457            // This also avoids weird padding/grouping behavior for NaN/Infinity.
458            if !value.is_finite() {
459                return FluentValue::from(value.to_string());
460            }
461
462            let get_opt = |name: &str| -> Option<i64> {
463                match named_args.get(name) {
464                    Some(FluentValue::Number(n)) if n.value.is_finite() => Some(n.value.trunc() as i64),
465                    _ => None,
466                }
467            };
468
469            const MAX_FRAC: i64 = 20;
470            const MAX_SIG: i64 = 21;
471            const MAX_MIN_INT: i64 = 308;
472
473            let use_grouping = named_args
474                .get("useGrouping")
475                .map(|v| match v {
476                    FluentValue::String(s) => s.as_ref() != "false",
477                    _ => true,
478                })
479                .unwrap_or(true);
480
481            let min_integer_digits = get_opt("minimumIntegerDigits").map(|v| v.clamp(1, MAX_MIN_INT) as usize).unwrap_or(1);
482            let max_fraction_digits = get_opt("maximumFractionDigits").map(|v| v.clamp(0, MAX_FRAC) as usize);
483
484            let mut min_fraction_digits = get_opt("minimumFractionDigits").map(|v| v.clamp(0, MAX_FRAC) as usize).unwrap_or(0);
485            if let Some(maxf) = max_fraction_digits {
486                min_fraction_digits = min_fraction_digits.min(maxf);
487            }
488
489            let min_sig_raw = get_opt("minimumSignificantDigits").map(|v| v.clamp(1, MAX_SIG) as usize);
490            let max_sig_raw = get_opt("maximumSignificantDigits").map(|v| v.clamp(1, MAX_SIG) as usize);
491
492            let sig_mode = min_sig_raw.is_some() || max_sig_raw.is_some();
493
494            let text = if sig_mode {
495                let mut min_sig = min_sig_raw.unwrap_or(1);
496                let max_sig = max_sig_raw.unwrap_or(21);
497                if max_sig < min_sig {
498                    min_sig = max_sig;
499                }
500
501                // Only maxSig rounds. (If user didn't specify maxSig explicitly, don't round.)
502                let rounded = if let Some(user_max) = max_sig_raw {
503                    if value != 0.0 && value.is_finite() {
504                        let log10 = value.abs().log10();
505                        let magnitude = if log10.is_finite() { log10.floor() as isize } else { 0 };
506
507                        // shift decimal so we can round to `user_max` sig digits
508                        let power = -(magnitude - (user_max as isize - 1));
509                        let factor = 10f64.powi(power as i32);
510
511                        // If scaling over/underflows, skip rounding rather than producing NaN.
512                        if !factor.is_finite() || factor == 0.0 {
513                            value
514                        } else {
515                            let r = (value * factor).round() / factor;
516                            if r.is_finite() { r } else { value }
517                        }
518                    } else {
519                        value
520                    }
521                } else {
522                    value
523                };
524
525                // Avoid scientific notation when maxSig is specified by forcing fixed-point formatting
526                let mut s = if let Some(user_max) = max_sig_raw {
527                    let abs = rounded.abs();
528                    if abs != 0.0 && abs.is_finite() {
529                        let magnitude = abs.log10().floor() as isize;
530                        let frac = ((user_max as isize - 1) - magnitude).max(0) as usize;
531                        format!("{:.*}", frac, rounded)
532                    } else {
533                        rounded.to_string()
534                    }
535                } else {
536                    // minSig only: don't truncate/round
537                    rounded.to_string()
538                };
539
540                // If this ends up in scientific notation, don't try to manipulate digits.
541                if s.contains('e') || s.contains('E') {
542                    return FluentValue::from(s);
543                }
544
545                // maxSig must not force trailing zeros
546                if s.as_bytes().contains(&b'.') {
547                    while s.ends_with('0') {
548                        s.pop();
549                    }
550                    if s.ends_with('.') {
551                        s.pop();
552                    }
553                }
554
555                // minSig pads (does not change value)
556                // Count significant digits: strip sign + '.', trim leading zeros
557                let mut seen_nonzero = false;
558                let mut current_sig = 0usize;
559
560                for &b in s.as_bytes() {
561                    if b.is_ascii_digit() {
562                        if !seen_nonzero {
563                            if b != b'0' {
564                                seen_nonzero = true;
565                                current_sig += 1;
566                            }
567                        } else {
568                            current_sig += 1;
569                        }
570                    }
571                }
572
573                if current_sig == 0 {
574                    // number is effectively 0 -> "0" or "0.00..."
575                    if min_sig <= 1 {
576                        s.clear();
577                        s.push('0');
578                    } else {
579                        s.clear();
580                        s.push('0');
581                        s.push('.');
582                        for _ in 0..(min_sig - 1) {
583                            s.push('0');
584                        }
585                    }
586                } else if current_sig < min_sig {
587                    let needed = min_sig - current_sig;
588                    if !s.as_bytes().contains(&b'.') {
589                        s.push('.');
590                    }
591                    for _ in 0..needed {
592                        s.push('0');
593                    }
594                }
595
596                s
597            } else {
598                // Fraction mode
599                if let Some(max_frac) = max_fraction_digits {
600                    let mut s = format!("{0:.1$}", value, max_frac);
601
602                    // trim if we were just limiting, not padding
603                    if s.as_bytes().contains(&b'.') {
604                        while s.ends_with('0') {
605                            s.pop();
606                        }
607                        if s.ends_with('.') {
608                            s.pop();
609                        }
610                    }
611
612                    // ensure minFraction padding
613                    if min_fraction_digits > 0 {
614                        let dot = s.find('.');
615                        let have = match dot {
616                            Some(d) => s.len().saturating_sub(d + 1),
617                            None => 0,
618                        };
619
620                        if have < min_fraction_digits {
621                            if dot.is_none() {
622                                s.push('.');
623                            }
624                            for _ in 0..(min_fraction_digits - have) {
625                                s.push('0');
626                            }
627                        }
628                    }
629
630                    s
631                } else {
632                    // No maxFraction: default shortest formatting
633                    let mut s = value.to_string();
634
635                    // If we got scientific notation, don't try to group/pad it.
636                    if s.contains('e') || s.contains('E') {
637                        return FluentValue::from(s);
638                    }
639
640                    // If minFraction is set, pad with zeros but don't round/truncate.
641                    if min_fraction_digits > 0 {
642                        let dot = s.find('.');
643                        let have = match dot {
644                            Some(d) => s.len().saturating_sub(d + 1),
645                            None => 0,
646                        };
647
648                        if have < min_fraction_digits {
649                            if dot.is_none() {
650                                s.push('.');
651                            }
652                            for _ in 0..(min_fraction_digits - have) {
653                                s.push('0');
654                            }
655                        }
656                    }
657
658                    s
659                }
660            };
661
662            // If we ever ended up with scientific notation, leave it alone.
663            if text.contains('e') || text.contains('E') {
664                return FluentValue::from(text);
665            }
666
667            let (int_slice, frac_slice) = if let Some(dot) = text.find('.') {
668                (&text[..dot], &text[dot..])
669            } else {
670                (text.as_str(), "")
671            };
672
673            // Pad integer to minimumIntegerDigits
674            let neg = int_slice.starts_with('-');
675            let digits = if neg { &int_slice[1..] } else { int_slice };
676            let needed = min_integer_digits.saturating_sub(digits.len());
677
678            let mut int_part = String::with_capacity(int_slice.len() + needed);
679            if neg {
680                int_part.push('-');
681            }
682            for _ in 0..needed {
683                int_part.push('0');
684            }
685            int_part.push_str(digits);
686
687            // Grouping: commas every 3 digits
688            if use_grouping {
689                let neg = int_part.starts_with('-');
690                let start = if neg { 1 } else { 0 };
691                let digits = &int_part[start..];
692
693                if digits.len() > 3 {
694                    let mut grouped = String::with_capacity(int_part.len() + (digits.len() / 3));
695                    if neg {
696                        grouped.push('-');
697                    }
698
699                    let offset = digits.len() % 3;
700
701                    if offset > 0 {
702                        grouped.push_str(&digits[..offset]);
703                        grouped.push(',');
704                    }
705
706                    for (i, b) in digits[offset..].bytes().enumerate() {
707                        if i > 0 && i % 3 == 0 {
708                            grouped.push(',');
709                        }
710                        grouped.push(b as char);
711                    }
712
713                    int_part = grouped;
714                }
715            }
716
717            if !frac_slice.is_empty() {
718                int_part.push_str(frac_slice);
719            }
720
721            FluentValue::from(int_part)
722        });
723    }
724
725    #[cfg(feature = "icu")]
726    fn add_custom_formatters(bundle: &mut FluentBundle<FluentResource>, locales: &[LanguageIdentifier]) {
727        use icu::locale::locale;
728
729        let icu_locale = locales.first().and_then(|l| l.to_string().parse().ok()).unwrap_or(locale!("en-US"));
730
731        let _ = bundle.add_function("NUMBER", {
732            let icu_locale = icu_locale.clone();
733            move |args, named_args| {
734                let num_value = match args.first() {
735                    Some(FluentValue::Number(n)) => n,
736                    Some(other) => return other.clone(),
737                    None => return FluentValue::Error,
738                };
739
740                // If we can't handle it as a number, return it unchanged.
741                if !num_value.value.is_finite() {
742                    return FluentValue::from(num_value.value.to_string());
743                }
744
745                let mut options = DecimalFormatterOptions::default();
746
747                // Handle useGrouping
748                if let Some(FluentValue::String(s)) = named_args.get("useGrouping") {
749                    if s.as_ref() == "false" {
750                        options.grouping_strategy = Some(GroupingStrategy::Never);
751                    } else if s.as_ref() == "always" {
752                        options.grouping_strategy = Some(GroupingStrategy::Always);
753                    }
754                }
755
756                let mut decimal = match Decimal::try_from_f64(num_value.value, FloatPrecision::RoundTrip) {
757                    Ok(d) => d,
758                    Err(_) => return FluentValue::from(num_value.value.to_string()),
759                };
760
761                let get_opt = |name: &str| -> Option<i64> {
762                    match named_args.get(name) {
763                        Some(FluentValue::Number(n)) if n.value.is_finite() => Some(n.value.trunc() as i64),
764                        _ => None,
765                    }
766                };
767
768                // These ranges mirror common Intl/ECMA-402 expectations.
769                const MAX_FRAC: i64 = 20;
770                const MAX_SIG: i64 = 21;
771                const MAX_MIN_INT: i64 = 308;
772
773                let min_sig_raw = get_opt("minimumSignificantDigits").map(|v| v.clamp(1, MAX_SIG) as i16);
774                let max_sig_raw = get_opt("maximumSignificantDigits").map(|v| v.clamp(1, MAX_SIG) as i16);
775
776                // Enforce min <= max when both are present
777                let (min_sig, max_sig) = match (min_sig_raw, max_sig_raw) {
778                    (Some(a), Some(b)) => (Some(a.min(b)), Some(b)),
779                    other => other,
780                };
781
782                let mut sig_applied = false;
783                let val_abs = num_value.value.abs();
784
785                // Significant Digits Logic
786                // Note: Max significant digits limits precision. If the result ends in zeros that are not required
787                // by minimumSignificantDigits, they should be trimmed.
788                if let Some(max) = max_sig {
789                    let magnitude = if val_abs != 0.0 { val_abs.log10().floor() as i16 } else { 0 };
790                    let position = magnitude - (max - 1);
791                    decimal.round(position);
792                    decimal.trim_end(); // Remove zeros that might have been added or kept by rounding logic
793                    sig_applied = true;
794                }
795
796                if let Some(min) = min_sig {
797                    let magnitude = if val_abs != 0.0 { val_abs.log10().floor() as i16 } else { 0 };
798                    let position = magnitude - (min - 1);
799                    decimal.pad_end(position); // Ensure we have at least this many significant digits
800                    sig_applied = true;
801                }
802
803                // Fraction Digits Logic
804                if !sig_applied {
805                    let min_frac_raw = get_opt("minimumFractionDigits").map(|v| v.clamp(0, MAX_FRAC) as i16);
806                    let max_frac_raw = get_opt("maximumFractionDigits").map(|v| v.clamp(0, MAX_FRAC) as i16);
807
808                    // Enforce min <= max when both are present
809                    let (min_frac, max_frac) = match (min_frac_raw, max_frac_raw) {
810                        (Some(a), Some(b)) => (Some(a.min(b)), Some(b)),
811                        other => other,
812                    };
813
814                    // Min Fraction: Pad
815                    if let Some(min_frac) = min_frac {
816                        decimal.pad_end(-min_frac);
817                    }
818
819                    // Max Fraction: Round (truncate precision), but don't force padding if the number is already shorter.
820                    // FixedDecimal::round() will pad if the number is less precise than the round magnitude.
821                    // We check magnitude_range start to avoid this.
822                    if let Some(max_frac) = max_frac {
823                        let limit = -max_frac;
824                        // Only round if the number currently extends beyond the limit (i.e. is more precise)
825                        if *decimal.magnitude_range().start() < limit {
826                            decimal.round(limit);
827                        }
828                    }
829                }
830
831                // Integer Digits
832                if let Some(min_int) = get_opt("minimumIntegerDigits").map(|v| v.clamp(1, MAX_MIN_INT) as i16) {
833                    decimal.pad_start(min_int);
834                }
835
836                // Create Formatter
837                let formatter: DecimalFormatter = match DecimalFormatter::try_new(icu_locale.clone().into(), options) {
838                    Ok(fmt) => fmt,
839                    Err(_) => return FluentValue::from(num_value.value.to_string()),
840                };
841
842                FluentValue::from(formatter.format(&decimal).to_string())
843            }
844        });
845
846        let _ = bundle.add_function("DATETIME", {
847            let icu_locale = icu_locale.clone();
848            move |args, named_args| {
849                let fallback_value = match args.first() {
850                    Some(v) => v.clone(),
851                    None => return FluentValue::Error,
852                };
853
854                let parse_date_length = |val: &FluentValue| -> Option<Length> {
855                    if let FluentValue::String(s) = val {
856                        match s.as_ref() {
857                            "long" => Some(Length::Long),
858                            "medium" => Some(Length::Medium),
859                            "short" => Some(Length::Short),
860                            _ => None,
861                        }
862                    } else {
863                        None
864                    }
865                };
866
867                let parse_time_length = |val: &FluentValue| -> Option<Length> {
868                    if let FluentValue::String(s) = val {
869                        match s.as_ref() {
870                            "long" => Some(Length::Long),
871                            "medium" => Some(Length::Medium),
872                            "short" => Some(Length::Short),
873                            _ => None,
874                        }
875                    } else {
876                        None
877                    }
878                };
879
880                let date_style = named_args.get("dateStyle").and_then(parse_date_length);
881                let time_style = named_args.get("timeStyle").and_then(parse_time_length);
882
883                let effective_date_style = date_style.or_else(|| if time_style.is_none() { Some(Length::Medium) } else { None });
884
885                enum DateOrTime {
886                    Date(icu::calendar::Date<Iso>),
887                    Time(icu::datetime::input::Time),
888                    DateTime(icu::datetime::input::DateTime<Iso>),
889                }
890
891                let to_icu_date =
892                    |d: Date| -> Option<icu::calendar::Date<Iso>> { icu::calendar::Date::try_new_iso(d.year(), u8::from(d.month()), d.day()).ok() };
893
894                let to_icu_time = |t: Time| -> Option<icu::datetime::input::Time> {
895                    icu::datetime::input::Time::try_new(t.hour(), t.minute(), t.second(), t.nanosecond()).ok()
896                };
897
898                let to_icu_datetime = |dt: OffsetDateTime| -> Option<icu::datetime::input::DateTime<Iso>> {
899                    let d = dt.date();
900                    let t = dt.time();
901                    let date = to_icu_date(d)?;
902                    let time = to_icu_time(t)?;
903                    Some(icu::datetime::input::DateTime { date, time })
904                };
905
906                let input: DateOrTime = match args.first() {
907                    Some(FluentValue::Custom(custom)) => {
908                        if let Some(arg) = custom.as_any().downcast_ref::<LocalizedArg>() {
909                            match arg {
910                                LocalizedArg::ConstDate(d) => {
911                                    let date = match to_icu_date(*d) {
912                                        Some(v) => v,
913                                        None => return fallback_value,
914                                    };
915                                    DateOrTime::Date(date)
916                                }
917                                LocalizedArg::VarDate(v) => {
918                                    let date = match v.get().and_then(to_icu_date) {
919                                        Some(v) => v,
920                                        None => return fallback_value,
921                                    };
922                                    DateOrTime::Date(date)
923                                }
924                                LocalizedArg::ConstTime(t) => {
925                                    let time = match to_icu_time(*t) {
926                                        Some(v) => v,
927                                        None => return fallback_value,
928                                    };
929                                    DateOrTime::Time(time)
930                                }
931                                LocalizedArg::VarTime(v) => {
932                                    let time = match v.get().and_then(to_icu_time) {
933                                        Some(v) => v,
934                                        None => return fallback_value,
935                                    };
936                                    DateOrTime::Time(time)
937                                }
938                                LocalizedArg::ConstDateTime(dt) => {
939                                    let dt = match to_icu_datetime(*dt) {
940                                        Some(v) => v,
941                                        None => return fallback_value,
942                                    };
943                                    DateOrTime::DateTime(dt)
944                                }
945                                LocalizedArg::VarDateTime(v) => {
946                                    let dt = match v.get().and_then(to_icu_datetime) {
947                                        Some(v) => v,
948                                        None => return fallback_value,
949                                    };
950                                    DateOrTime::DateTime(dt)
951                                }
952
953                                // If DATETIME gets a non-date/time LocalizedArg (string, number, etc), just fallback.
954                                _ => return fallback_value,
955                            }
956                        } else {
957                            // Unknown custom type: pass through unchanged.
958                            return fallback_value;
959                        }
960                    }
961                    Some(FluentValue::String(s)) => match icu::datetime::input::DateTime::<Iso>::from_str(s.as_ref()) {
962                        Ok(dt) => DateOrTime::DateTime(dt),
963                        Err(_) => return FluentValue::from(s.clone()),
964                    },
965                    Some(v) => return v.clone(),
966                    None => return FluentValue::Error,
967                };
968
969                // Format according to requested styles
970                let formatted = match (effective_date_style, time_style) {
971                    (Some(date_len), Some(time_len)) => {
972                        let dt = match &input {
973                            DateOrTime::DateTime(dt) => dt,
974                            _ => return fallback_value,
975                        };
976
977                        let ymd = fieldsets::YMD::for_length(date_len);
978                        let ymdt = match time_len {
979                            Length::Short => ymd.with_time_hm(),
980                            Length::Medium | Length::Long => ymd.with_time_hms(),
981                            _ => ymd.with_time_hms(),
982                        };
983
984                        match DateTimeFormatter::<DateAndTimeFieldSet>::try_new(icu_locale.clone().into(), DateAndTimeFieldSet::YMDT(ymdt)) {
985                            Ok(fmt) => fmt.format(dt).to_string(),
986                            Err(_) => return fallback_value,
987                        }
988                    }
989                    (Some(date_len), None) => {
990                        let ymd = fieldsets::YMD::for_length(date_len);
991
992                        let fmt = match DateTimeFormatter::<DateFieldSet>::try_new(icu_locale.clone().into(), DateFieldSet::YMD(ymd)) {
993                            Ok(fmt) => fmt,
994                            Err(_) => return fallback_value,
995                        };
996
997                        match &input {
998                            DateOrTime::Date(date) => fmt.format(date).to_string(),
999                            DateOrTime::DateTime(dt) => fmt.format(dt).to_string(),
1000                            DateOrTime::Time(_) => return fallback_value,
1001                        }
1002                    }
1003                    (None, Some(time_len)) => {
1004                        let tf = match time_len {
1005                            Length::Short => fieldsets::T::hm().with_length(time_len),
1006                            Length::Medium | Length::Long => fieldsets::T::hms().with_length(time_len),
1007                            _ => fieldsets::T::hms().with_length(time_len),
1008                        };
1009
1010                        let fmt = match DateTimeFormatter::<TimeFieldSet>::try_new(icu_locale.clone().into(), TimeFieldSet::T(tf)) {
1011                            Ok(fmt) => fmt,
1012                            Err(_) => return fallback_value,
1013                        };
1014
1015                        match &input {
1016                            DateOrTime::Time(time) => fmt.format(time).to_string(),
1017                            DateOrTime::DateTime(dt) => fmt.format(dt).to_string(),
1018                            DateOrTime::Date(_) => return fallback_value,
1019                        }
1020                    }
1021                    (None, None) => return fallback_value,
1022                };
1023
1024                FluentValue::from(formatted)
1025            }
1026        });
1027    }
1028}
1029
1030/// A concrete argument type for localization.
1031#[derive(Clone, Debug, PartialEq)]
1032#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
1033pub enum LocalizedArg {
1034    ConstString(Cow<'static, str>),
1035    ConstNumber(f64),
1036    VarString(WeakVar<String>),
1037    VarNumber(WeakVar<f64>),
1038    #[cfg(feature = "icu")]
1039    ConstTime(Time),
1040    #[cfg(feature = "icu")]
1041    VarTime(WeakVar<Time>),
1042    #[cfg(feature = "icu")]
1043    ConstDate(Date),
1044    #[cfg(feature = "icu")]
1045    VarDate(WeakVar<Date>),
1046    #[cfg(feature = "icu")]
1047    ConstDateTime(OffsetDateTime),
1048    #[cfg(feature = "icu")]
1049    VarDateTime(WeakVar<OffsetDateTime>),
1050}
1051
1052#[cfg(feature = "icu")]
1053impl FluentType for LocalizedArg {
1054    fn duplicate(&self) -> Box<dyn FluentType + Send> {
1055        Box::new(self.clone())
1056    }
1057
1058    fn as_string(&self, _intls: &intl_memoizer::IntlLangMemoizer) -> Cow<'static, str> {
1059        // Fallback string representation. In normal use, the DATETIME formatter
1060        // formats the date variants directly.
1061        match self {
1062            LocalizedArg::ConstString(s) => s.clone(),
1063            LocalizedArg::ConstNumber(n) => Cow::Owned(n.to_string()),
1064            LocalizedArg::VarString(v) => v.get().map(|val| Cow::Owned(val.clone())).unwrap_or_default(),
1065            LocalizedArg::VarNumber(v) => v.get().map(|val| Cow::Owned(val.to_string())).unwrap_or_default(),
1066            LocalizedArg::ConstTime(t) => Cow::Owned(format!("{}", t)),
1067            LocalizedArg::VarTime(v) => v.get().map(|t| Cow::Owned(format!("{}", t))).unwrap_or_default(),
1068            LocalizedArg::ConstDate(d) => Cow::Owned(format!("{}", d)),
1069            LocalizedArg::VarDate(v) => v.get().map(|d| Cow::Owned(format!("{}", d))).unwrap_or_default(),
1070            LocalizedArg::ConstDateTime(dt) => Cow::Owned(format!("{}", dt)),
1071            LocalizedArg::VarDateTime(v) => v.get().map(|dt| Cow::Owned(format!("{}", dt))).unwrap_or_default(),
1072        }
1073    }
1074
1075    fn as_string_threadsafe(&self, _intls: &intl_memoizer::concurrent::IntlLangMemoizer) -> Cow<'static, str> {
1076        // Same as `as_string`, but for the threadsafe memoizer.
1077        match self {
1078            LocalizedArg::ConstString(s) => s.clone(),
1079            LocalizedArg::ConstNumber(n) => Cow::Owned(n.to_string()),
1080            LocalizedArg::VarString(v) => v.get().map(|val| Cow::Owned(val.clone())).unwrap_or_default(),
1081            LocalizedArg::VarNumber(v) => v.get().map(|val| Cow::Owned(val.to_string())).unwrap_or_default(),
1082            LocalizedArg::ConstTime(t) => Cow::Owned(format!("{}", t)),
1083            LocalizedArg::VarTime(v) => v.get().map(|t| Cow::Owned(format!("{}", t))).unwrap_or_default(),
1084            LocalizedArg::ConstDate(d) => Cow::Owned(format!("{}", d)),
1085            LocalizedArg::VarDate(v) => v.get().map(|d| Cow::Owned(format!("{}", d))).unwrap_or_default(),
1086            LocalizedArg::ConstDateTime(dt) => Cow::Owned(format!("{}", dt)),
1087            LocalizedArg::VarDateTime(v) => v.get().map(|dt| Cow::Owned(format!("{}", dt))).unwrap_or_default(),
1088        }
1089    }
1090}
1091
1092impl LocalizedArg {
1093    fn to_fluent<'a>(&'a self) -> Option<FluentValue<'a>> {
1094        match self {
1095            LocalizedArg::ConstString(s) => Some(FluentValue::String(Cow::Borrowed(s.as_ref()))),
1096            LocalizedArg::ConstNumber(n) => Some((*n).into()),
1097            LocalizedArg::VarString(v) => Some(v.get()?.into()),
1098            LocalizedArg::VarNumber(v) => Some(v.get()?.into()),
1099            #[cfg(feature = "icu")]
1100            LocalizedArg::ConstTime(_)
1101            | LocalizedArg::VarTime(_)
1102            | LocalizedArg::ConstDate(_)
1103            | LocalizedArg::VarDate(_)
1104            | LocalizedArg::ConstDateTime(_)
1105            | LocalizedArg::VarDateTime(_) => {
1106                match self {
1107                    LocalizedArg::VarTime(v) if !v.is_alive() => return None,
1108                    LocalizedArg::VarDate(v) if !v.is_alive() => return None,
1109                    LocalizedArg::VarDateTime(v) if !v.is_alive() => return None,
1110                    _ => {}
1111                }
1112                Some(FluentValue::Custom(Box::new(self.clone())))
1113            }
1114        }
1115    }
1116
1117    fn get_key(&self) -> Option<VarKey> {
1118        match self {
1119            LocalizedArg::VarString(v) => Some(v.get_key()),
1120            LocalizedArg::VarNumber(v) => Some(v.get_key()),
1121            #[cfg(feature = "icu")]
1122            LocalizedArg::VarTime(v) => Some(v.get_key()),
1123            #[cfg(feature = "icu")]
1124            LocalizedArg::VarDate(v) => Some(v.get_key()),
1125            #[cfg(feature = "icu")]
1126            LocalizedArg::VarDateTime(v) => Some(v.get_key()),
1127            _ => None,
1128        }
1129    }
1130
1131    fn get_version(&self) -> Option<u64> {
1132        match self {
1133            LocalizedArg::VarString(v) => v.get_version(),
1134            LocalizedArg::VarNumber(v) => v.get_version(),
1135            #[cfg(feature = "icu")]
1136            LocalizedArg::VarTime(v) => v.get_version(),
1137            #[cfg(feature = "icu")]
1138            LocalizedArg::VarDate(v) => v.get_version(),
1139            #[cfg(feature = "icu")]
1140            LocalizedArg::VarDateTime(v) => v.get_version(),
1141            _ => Some(0),
1142        }
1143    }
1144}
1145
1146impl From<&'static str> for LocalizedArg {
1147    fn from(s: &'static str) -> Self {
1148        LocalizedArg::ConstString(Cow::Borrowed(s))
1149    }
1150}
1151
1152impl From<String> for LocalizedArg {
1153    fn from(s: String) -> Self {
1154        LocalizedArg::ConstString(Cow::Owned(s))
1155    }
1156}
1157
1158impl From<Cow<'static, str>> for LocalizedArg {
1159    fn from(c: Cow<'static, str>) -> Self {
1160        LocalizedArg::ConstString(c)
1161    }
1162}
1163
1164impl From<f64> for LocalizedArg {
1165    fn from(n: f64) -> Self {
1166        LocalizedArg::ConstNumber(n)
1167    }
1168}
1169
1170impl From<WeakVar<String>> for LocalizedArg {
1171    fn from(v: WeakVar<String>) -> Self {
1172        LocalizedArg::VarString(v)
1173    }
1174}
1175
1176impl From<WeakVar<f64>> for LocalizedArg {
1177    fn from(v: WeakVar<f64>) -> Self {
1178        LocalizedArg::VarNumber(v)
1179    }
1180}
1181
1182#[cfg(feature = "icu")]
1183impl From<Time> for LocalizedArg {
1184    fn from(t: Time) -> Self {
1185        LocalizedArg::ConstTime(t)
1186    }
1187}
1188
1189#[cfg(feature = "icu")]
1190impl From<Date> for LocalizedArg {
1191    fn from(d: Date) -> Self {
1192        LocalizedArg::ConstDate(d)
1193    }
1194}
1195
1196#[cfg(feature = "icu")]
1197impl From<OffsetDateTime> for LocalizedArg {
1198    fn from(dt: OffsetDateTime) -> Self {
1199        LocalizedArg::ConstDateTime(dt)
1200    }
1201}
1202
1203#[cfg(feature = "icu")]
1204impl From<WeakVar<Time>> for LocalizedArg {
1205    fn from(v: WeakVar<Time>) -> Self {
1206        LocalizedArg::VarTime(v)
1207    }
1208}
1209
1210#[cfg(feature = "icu")]
1211impl From<WeakVar<Date>> for LocalizedArg {
1212    fn from(v: WeakVar<Date>) -> Self {
1213        LocalizedArg::VarDate(v)
1214    }
1215}
1216
1217#[cfg(feature = "icu")]
1218impl From<WeakVar<OffsetDateTime>> for LocalizedArg {
1219    fn from(v: WeakVar<OffsetDateTime>) -> Self {
1220        LocalizedArg::VarDateTime(v)
1221    }
1222}
1223
1224/// Used to construct a [`LocalizedString`]
1225pub struct LocalizedStringBuilder {
1226    key: &'static str,
1227    placeholder: Option<&'static str>,
1228    args: Vec<(Cow<'static, str>, LocalizedArg)>,
1229}
1230
1231impl LocalizedStringBuilder {
1232    /// Creates a new builder. `key` is used to look up translations in a [`TranslationFile`].
1233    pub fn new(key: &'static str) -> Self {
1234        Self {
1235            key,
1236            placeholder: None,
1237            args: Vec::new(),
1238        }
1239    }
1240
1241    /// Provides a placeholder string to display when resolving fails.
1242    pub fn placeholder(mut self, text: &'static str) -> Self {
1243        self.placeholder = Some(text);
1244        self
1245    }
1246
1247    /// Adds a value to be used as a localization argument when resolving the string.
1248    /// This can be a static value or a reactive variable.
1249    pub fn arg(mut self, key: &'static str, value: impl Into<LocalizedArg>) -> Self {
1250        self.args.push((Cow::Borrowed(key), value.into()));
1251        self
1252    }
1253
1254    /// Builds the LocalizedString.
1255    pub fn build(self) -> LocalizedString {
1256        // VarKey contains a reference to mutable values
1257        // but the Hash and PartialEq impls use pointer values
1258        // so it's safe to use as a key in a hashmap.
1259        #[allow(clippy::mutable_key_type)]
1260        let mut dependencies = DependencyMap::default();
1261        for (_, arg) in &self.args {
1262            if let (Some(key), Some(version)) = (arg.get_key(), arg.get_version()) {
1263                dependencies.record(key, version);
1264            }
1265        }
1266
1267        LocalizedString {
1268            inner: Arc::new(RwLock::new(LocalizedStringInner {
1269                last_loaded: None,
1270                key: Cow::Borrowed(self.key),
1271                placeholder: self.placeholder.map(Cow::Borrowed),
1272                args: self.args,
1273                last_locale: None,
1274                resolved_string: None,
1275            })),
1276        }
1277    }
1278}
1279
1280struct LocalizedStringInner {
1281    last_loaded: Option<Instant>,
1282    key: Cow<'static, str>,
1283    placeholder: Option<Cow<'static, str>>,
1284    args: Vec<(Cow<'static, str>, LocalizedArg)>,
1285    last_locale: Option<LanguageIdentifier>,
1286    resolved_string: Option<String>,
1287}
1288
1289impl fmt::Debug for LocalizedStringInner {
1290    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1291        f.debug_struct("LocalizedString")
1292            .field("key", &self.key)
1293            .field("placeholder", &self.placeholder)
1294            .field("args", &self.args.len())
1295            .field("last_locale", &self.last_locale)
1296            .finish()
1297    }
1298}
1299
1300/// Represents a string that can be localized into different languages.
1301///
1302/// Constructed with [`LocalizedStringBuilder`].
1303///
1304/// The contents are stored in an [`Arc<T>`] so it's cheap to clone.
1305#[derive(Clone)]
1306pub struct LocalizedString {
1307    inner: Arc<RwLock<LocalizedStringInner>>,
1308}
1309
1310impl fmt::Debug for LocalizedString {
1311    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1312        self.inner.read().fmt(f)
1313    }
1314}
1315
1316impl LocalizedString {
1317    /// Resolves the string against the translation map
1318    pub fn resolve(&self, translation_map: &TranslationMap) -> MappedRwLockReadGuard<'_, str> {
1319        let map_guard = translation_map.inner.read();
1320        let current_locale = &map_guard.current_locale;
1321
1322        let mut write_guard = self.inner.write();
1323        write_guard.resolved_string = None;
1324        write_guard.last_loaded = Some(map_guard.last_loaded);
1325        write_guard.last_locale = Some(current_locale.clone());
1326
1327        // Attempt to resolve
1328        if let Some(file) = map_guard.translations.get(current_locale)
1329            && let Some(msg) = file.bundle.get_message(write_guard.key.as_ref())
1330            && let Some(value) = msg.value()
1331        {
1332            let mut args = FluentArgs::new();
1333            for (key, arg) in &write_guard.args {
1334                if let Some(fluent) = arg.to_fluent() {
1335                    args.set(key.as_ref(), fluent);
1336                }
1337            }
1338
1339            let mut errors = Vec::new();
1340            let resolved = file.bundle.format_pattern(value, Some(&args), &mut errors).into_owned();
1341
1342            if errors.is_empty() {
1343                write_guard.resolved_string = Some(resolved);
1344            } else {
1345                for error in errors {
1346                    error!("{error}");
1347                }
1348                // fallback to placeholder/key
1349                write_guard.resolved_string = None;
1350            }
1351        }
1352
1353        let read_guard = RwLockWriteGuard::downgrade(write_guard);
1354        RwLockReadGuard::map(read_guard, |inner| {
1355            if let Some(text) = &inner.resolved_string {
1356                text.as_str()
1357            } else {
1358                inner.placeholder.as_deref().unwrap_or(inner.key.as_ref())
1359            }
1360        })
1361    }
1362}
1363
1364#[cfg(feature = "serde")]
1365mod serde_impl {
1366    use super::*;
1367
1368    use serde::{Deserialize, Deserializer, Serialize, Serializer};
1369
1370    #[derive(Serialize, Deserialize)]
1371    struct LocalizedStringSerde {
1372        key: Cow<'static, str>,
1373        placeholder: Option<Cow<'static, str>>,
1374        args: Vec<(Cow<'static, str>, LocalizedArg)>,
1375    }
1376
1377    impl Serialize for LocalizedString {
1378        fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
1379        where
1380            S: Serializer,
1381        {
1382            let inner = self.inner.read();
1383
1384            let ser = LocalizedStringSerde {
1385                key: inner.key.clone(),
1386                placeholder: inner.placeholder.clone(),
1387                args: inner.args.clone(),
1388            };
1389
1390            ser.serialize(serializer)
1391        }
1392    }
1393
1394    impl<'de> Deserialize<'de> for LocalizedString {
1395        fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1396        where
1397            D: Deserializer<'de>,
1398        {
1399            let de = LocalizedStringSerde::deserialize(deserializer)?;
1400
1401            // VarKey contains a reference to mutable values
1402            // but the Hash and PartialEq impls use pointer values
1403            // so it's safe to use as a key in a hashmap.
1404            #[allow(clippy::mutable_key_type)]
1405            let mut dependencies = DependencyMap::default();
1406            for (_, arg) in &de.args {
1407                if let (Some(key), Some(version)) = (arg.get_key(), arg.get_version()) {
1408                    dependencies.record(key, version);
1409                }
1410            }
1411
1412            Ok(LocalizedString {
1413                inner: Arc::new(RwLock::new(LocalizedStringInner {
1414                    last_loaded: None,
1415                    key: de.key,
1416                    placeholder: de.placeholder,
1417                    args: de.args,
1418                    last_locale: None,
1419                    resolved_string: None,
1420                })),
1421            })
1422        }
1423    }
1424}