Skip to main content

fluent_zero/
lib.rs

1//! # fluent-zero
2//!
3//! A zero-allocation, high-performance Fluent localization loader designed for
4//! GUIs and games.
5//!
6//! This crate works in tandem with the `fluent-zero-build` crate. The build script
7//! generates static Perfect Hash Maps (PHF) that allow `O(1)` lookups for localized
8//! strings. When a string is static (contains no variables), it returns a `&'static str`
9//! reference to the binary's read-only data, avoiding all heap allocations.
10//!
11//! The public API is [`t!`], [`t_in!`], [`set_lang`], [`get_lang`],
12//! [`LocaleState`], [`Catalog`], [`CacheEntry`], [`DEFAULT_FALLBACK`],
13//! [`join_charsets`], the runtime diagnostics ([`set_error_handler`],
14//! [`clear_error_handler`], [`LookupError`], [`FormatErrors`],
15//! [`ErrorHandler`]) and the re-exported [`LanguageIdentifier`] and
16//! [`FluentArgs`]. Everything under `__private` exists only for the code
17//! `fluent-zero-build` generates and the macros; it is not covered by semver
18//! and may change in any release.
19//!
20//! # Example
21//!
22//! ```rust,standalone_crate
23//! # use std::sync::LazyLock;
24//! # use fluent_zero::{
25//! #     CacheEntry,
26//! #     __private::{BundleCollection, CacheStore, ConcurrentFluentBundle, FluentResource},
27//! # };
28//! # // Hand-written stand-ins for the `CATALOG` static and the key list that
29//! # // `fluent-zero-build` generates.
30//! # struct Catalog;
31//! # impl CacheStore for Catalog {
32//! #     fn get_entry(&self, lang: &str, key: &str) -> Option<CacheEntry> {
33//! #         match (lang, key) {
34//! #             ("en-US", "app-title") => Some(CacheEntry::Static("My App")),
35//! #             ("fr-FR", "app-title") => Some(CacheEntry::Static("Mon appli")),
36//! #             ("en-US", "welcome-user") => Some(CacheEntry::Dynamic),
37//! #             _ => None,
38//! #         }
39//! #     }
40//! # }
41//! # static EN_US: LazyLock<ConcurrentFluentBundle<FluentResource>> = LazyLock::new(|| {
42//! #     let mut bundle = ConcurrentFluentBundle::new_concurrent(vec!["en-US".parse().unwrap()]);
43//! #     bundle.set_use_isolating(false);
44//! #     let ftl = "welcome-user = Welcome, { $name }! ({ $unread_count } unread)";
45//! #     bundle.add_resource(FluentResource::try_new(ftl.into()).unwrap()).unwrap();
46//! #     bundle
47//! # });
48//! # impl BundleCollection for Catalog {
49//! #     fn get_bundle(&self, lang: &str) -> Option<&ConcurrentFluentBundle<FluentResource>> {
50//! #         (lang == "en-US").then(|| &*EN_US)
51//! #     }
52//! # }
53//! # static CATALOG: Catalog = Catalog;
54//! # const __FLUENT_ZERO_KEYS: &[&str] = &["app-title", "welcome-user"];
55//! // Brings the generated `CATALOG`, `keys` and key list into scope.
56//! // (Replaced by hand-written stand-ins in this example.)
57//! # /*
58//! include!(concat!(env!("OUT_DIR"), "/static_cache.rs"));
59//! # */
60//!
61//! use fluent_zero::{set_lang, t};
62//!
63//! fn main() {
64//!     // Static message: a borrowed `&'static str`, no allocation.
65//!     assert_eq!(t!("app-title"), "My App");
66//!
67//!     // Dynamic message: formatted by the Fluent bundle.
68//!     let welcome = t!("welcome-user", { "name" => "Alice", "unread_count" => 5 });
69//!     assert_eq!(welcome, "Welcome, Alice! (5 unread)");
70//!
71//!     // Switching language is global and takes effect immediately.
72//!     set_lang("fr-FR".parse().unwrap());
73//!     assert_eq!(t!("app-title"), "Mon appli");
74//!     assert_eq!(fluent_zero::get_lang().key(), "fr-FR");
75//!
76//!     // Messages missing from fr-FR fall back to en-US.
77//!     let welcome = t!("welcome-user", { "name" => "Bob", "unread_count" => 0 });
78//!     assert_eq!(welcome, "Welcome, Bob! (0 unread)");
79//! }
80//! ```
81
82#![forbid(unsafe_code)]
83#![warn(missing_docs)]
84#![deny(rustdoc::broken_intra_doc_links)]
85
86extern crate self as fluent_zero;
87
88use std::{
89    borrow::Cow,
90    sync::{Arc, LazyLock},
91};
92
93use arc_swap::{ArcSwap, ArcSwapOption};
94use fluent_bundle::FluentError;
95
96pub use fluent_bundle::FluentArgs;
97pub use unic_langid::LanguageIdentifier;
98
99use crate::__private::{BundleCollection, CacheStore, CatalogLocale};
100
101/// Represents the result of a cache lookup from the generated PHF map.
102///
103/// This enum allows the system to distinguish between zero-cost static strings
104/// and those that require the heavier `FluentBundle` machinery.
105#[derive(Debug, Clone, Copy, PartialEq, Eq)]
106pub enum CacheEntry {
107    /// The message is static and contains no variables.
108    ///
109    /// The payload is a direct reference to the string in the binary's data section.
110    Static(&'static str),
111    /// The message is dynamic (contains variables/selectors).
112    ///
113    /// This indicates that the system must load the Fluent bundle to resolve
114    /// the final string.
115    Dynamic,
116}
117
118/// A language and the locale keys a lookup tries for it.
119///
120/// The global language set by [`set_lang`] is one of these (see [`get_lang`]),
121/// and servers that render for several users at once can build one per user
122/// (or request) and pass it to [`t_in!`].
123///
124/// Building one resolves the fallback chain once (a few small allocations), so
125/// every lookup afterwards just walks a short precomputed slice of keys.
126#[derive(Debug)]
127pub struct LocaleState {
128    /// The parsed identifier (e.g., `en-US`).
129    id: LanguageIdentifier,
130    /// The string representation used for cache keys (e.g., "en-US").
131    key: String,
132    /// Candidate locale keys, most specific first: the identifier itself, then
133    /// with variants, script and region dropped in turn (`sr-Latn-RS` tries
134    /// `sr-Latn-RS`, `sr-Latn`, `sr-RS`, `sr`). The bare language is always
135    /// last; each crate's generated `CATALOG` maps it (and script-implying
136    /// tags such as `zh-TW`) to its best available locale, so `ru` finds
137    /// `ru-RU/` and `pt-BR` finds `pt-PT/` without negotiating per lookup.
138    chain: Box<[Box<str>]>,
139}
140
141impl LocaleState {
142    /// Resolves the fallback chain for `id`.
143    #[must_use]
144    pub fn new(id: LanguageIdentifier) -> Self {
145        let key = id.to_string();
146        let language = id.language;
147        let (script, region) = (id.script, id.region);
148        let mut chain: Vec<Box<str>> = vec![key.as_str().into()];
149        for (script, region) in [
150            (script, region),
151            (script, None),
152            (None, region),
153            (None, None),
154        ] {
155            let candidate =
156                LanguageIdentifier::from_parts(language, script, region, &[]).to_string();
157            if !chain.iter().any(|existing| **existing == *candidate) {
158                chain.push(candidate.into());
159            }
160        }
161        Self {
162            id,
163            key,
164            chain: chain.into_boxed_slice(),
165        }
166    }
167
168    /// The language identifier this state was built from (for the global
169    /// language, the one passed to [`set_lang`]; `en-US` by default).
170    #[must_use]
171    pub fn id(&self) -> &LanguageIdentifier {
172        &self.id
173    }
174
175    /// The canonical string form of [`id`](Self::id) (e.g. `"en-US"`), which is
176    /// the first key the generated caches are probed with.
177    #[must_use]
178    pub fn key(&self) -> &str {
179        &self.key
180    }
181
182    /// The locale keys a lookup tries, most specific first, before the
183    /// catalog's fallback locale.
184    pub fn chain(&self) -> impl ExactSizeIterator<Item = &str> {
185        self.chain.iter().map(|key| &**key)
186    }
187}
188
189impl From<LanguageIdentifier> for LocaleState {
190    fn from(id: LanguageIdentifier) -> Self {
191        Self::new(id)
192    }
193}
194
195/// The global thread-safe storage for the current language.
196///
197/// Uses `ArcSwap` to allow lock-free reads, which is critical for high-performance
198/// hot paths in GUI rendering loops.
199static CURRENT_LANG: LazyLock<ArcSwap<LocaleState>> = LazyLock::new(|| {
200    // `DEFAULT_FALLBACK` is a valid tag (see the unit test), so the default
201    // is unreachable; it only keeps a panic path out of the runtime.
202    let id: LanguageIdentifier = DEFAULT_FALLBACK.parse().unwrap_or_default();
203    ArcSwap::from_pointee(LocaleState::new(id))
204});
205
206/// The fallback locale of catalogs that do not configure one.
207///
208/// Generated code exports its own `FALLBACK` (see
209/// `FluentZeroBuilder::fallback_locale`), which defaults to this value.
210pub const DEFAULT_FALLBACK: &str = "en-US";
211
212/// Updates the runtime language for the application.
213///
214/// This operation is atomic. Subsequent calls to `t!` will immediately reflect
215/// the new language. The fallback chain (see [`LocaleState`]) is resolved here,
216/// once, rather than on every lookup.
217///
218/// # Arguments
219///
220/// * `lang` - The new `LanguageIdentifier` to set (e.g., parsed from "fr-FR").
221pub fn set_lang(lang: LanguageIdentifier) {
222    CURRENT_LANG.store(Arc::new(LocaleState::new(lang)));
223}
224
225/// Retrieves the current language state.
226///
227/// The returned `Arc` is a snapshot: a later [`set_lang`] does not change it.
228/// Lookups made by [`t!`] read the language directly and do not go through
229/// this function.
230#[must_use]
231pub fn get_lang() -> Arc<LocaleState> {
232    CURRENT_LANG.load_full()
233}
234
235/// A crate's generated lookup tables, as used by [`t!`] and [`t_in!`].
236///
237/// `fluent-zero-build` generates one as the `CATALOG` static at the root of
238/// each crate that includes its output. Besides each locale under its own key,
239/// a catalog answers for the language-level keys that end a fallback chain
240/// (`ru` when only `ru-RU/` exists, `zh-TW` when only `zh-Hant/` exists),
241/// resolved by `fluent-zero-build` against this crate's own locales. Each
242/// entry carries both the static cache and the bundle, so a lookup costs one
243/// map probe per candidate key.
244pub struct Catalog {
245    fallback: &'static str,
246    locales: &'static __private::phf::Map<&'static str, CatalogLocale>,
247}
248
249impl Catalog {
250    /// The locale tried when the language's own chain has no message.
251    #[must_use]
252    pub const fn fallback(&self) -> &'static str {
253        self.fallback
254    }
255
256    /// Every locale key this catalog answers for, including negotiation
257    /// aliases such as `pt` for `pt-PT`, in no particular order.
258    pub fn locales(&self) -> impl ExactSizeIterator<Item = &'static str> {
259        self.locales.keys().copied()
260    }
261}
262
263impl std::fmt::Debug for Catalog {
264    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
265        let mut locales: Vec<&str> = self.locales().collect();
266        locales.sort_unstable();
267        f.debug_struct("Catalog")
268            .field("fallback", &self.fallback)
269            .field("locales", &locales)
270            .finish()
271    }
272}
273
274/// Merges multiple charset strings into a single, deduplicated, and sorted string.
275///
276/// This function provides an enterprise-quality, build-system agnostic solution for
277/// aggregating characters for font subsetting. By combining the `CHARSET` constants
278/// generated natively by `fluent-zero-build` from your application and all its
279/// dependencies, you seamlessly compute a master charset without brittle `cargo_metadata`
280/// scripts or violating Cargo's `OUT_DIR` sandboxing limits.
281///
282/// # Arguments
283///
284/// * `charsets` - A slice of `&str` references, typically pointing to `crate::CHARSET`.
285///
286/// # Returns
287///
288/// A deterministically sorted `String` containing every unique character.
289///
290/// # Example
291///
292/// ```rust
293/// # mod my_ui_lib { pub const CHARSET: &str = "cab"; }
294/// # const CHARSET: &str = "abd";
295/// // Typically `crate::CHARSET` plus the `CHARSET` of each dependency.
296/// let complete_charset = fluent_zero::join_charsets(&[
297///     CHARSET,
298///     my_ui_lib::CHARSET,
299/// ]);
300/// assert_eq!(complete_charset, "abcd");
301/// ```
302#[must_use]
303pub fn join_charsets(charsets: &[&str]) -> String {
304    let mut unique_chars = std::collections::BTreeSet::new();
305    for charset in charsets {
306        unique_chars.extend(charset.chars());
307    }
308    unique_chars.into_iter().collect()
309}
310
311/// A localization problem observed at runtime.
312///
313/// Lookups never fail: a missing key renders as the key itself and a broken
314/// placeable renders as best `fluent-bundle` can. These errors are only
315/// delivered to the handler installed with [`set_error_handler`] (and logged
316/// with the optional `tracing` feature) so QA and telemetry can see them.
317#[derive(Debug, Clone, Copy, PartialEq)]
318#[non_exhaustive]
319pub enum LookupError<'a> {
320    /// The key resolved in neither the current nor the fallback language, so
321    /// the key itself was returned. For a `message.attribute` key this also
322    /// covers a message that exists but lacks that attribute.
323    MissingKey {
324        /// The message ID, or `message.attribute` key, that was looked up.
325        key: &'a str,
326        /// The language that was active (e.g. "fr-FR").
327        lang: &'a str,
328    },
329    /// The message exists in `lang` but has no value (only attributes), so the
330    /// bare message ID cannot be rendered; look up `message.attribute` instead.
331    /// The lookup then continues with the fallback language.
332    MissingValue {
333        /// The message ID that was looked up.
334        key: &'a str,
335        /// The language whose bundle holds the value-less message.
336        lang: &'a str,
337    },
338    /// Formatting succeeded only partially, typically because of a missing or
339    /// mistyped argument. The partially formatted string was still returned.
340    Format {
341        /// The message ID that was looked up.
342        key: &'a str,
343        /// The language whose bundle formatted the message.
344        lang: &'a str,
345        /// The errors reported by `fluent-bundle`.
346        errors: FormatErrors<'a>,
347    },
348}
349
350impl std::fmt::Display for LookupError<'_> {
351    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
352        match self {
353            Self::MissingKey { key, lang } => {
354                // Message IDs cannot contain `.`, so a dotted key is an attribute.
355                let what = if key.contains('.') {
356                    "attribute"
357                } else {
358                    "message"
359                };
360                write!(f, "missing {what} `{key}` (language {lang})")
361            }
362            Self::MissingValue { key, lang } => {
363                write!(f, "message `{key}` has no value (language {lang})")
364            }
365            Self::Format { key, lang, errors } => {
366                write!(f, "failed to format message `{key}` (language {lang})")?;
367                if !errors.is_empty() {
368                    write!(f, ": {errors}")?;
369                }
370                Ok(())
371            }
372        }
373    }
374}
375
376impl std::error::Error for LookupError<'_> {}
377
378/// The errors `fluent-bundle` reported while formatting one message, as carried
379/// by [`LookupError::Format`].
380///
381/// Displays as the individual errors joined with `"; "`. The concrete error
382/// type belongs to `fluent-bundle` and is deliberately not exposed, so each
383/// error is offered as a [`std::error::Error`] trait object.
384#[derive(Debug, Clone, Copy, PartialEq)]
385pub struct FormatErrors<'a>(&'a [FluentError]);
386
387impl<'a> FormatErrors<'a> {
388    /// The number of errors.
389    #[must_use]
390    pub fn len(&self) -> usize {
391        self.0.len()
392    }
393
394    /// Whether there are no errors.
395    #[must_use]
396    pub fn is_empty(&self) -> bool {
397        self.0.is_empty()
398    }
399
400    /// Iterates over the errors, in the order `fluent-bundle` reported them.
401    pub fn iter(&self) -> impl ExactSizeIterator<Item = &'a (dyn std::error::Error + 'static)> {
402        self.0
403            .iter()
404            .map(|error| error as &(dyn std::error::Error + 'static))
405    }
406}
407
408impl std::fmt::Display for FormatErrors<'_> {
409    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
410        for (i, error) in self.0.iter().enumerate() {
411            if i > 0 {
412                f.write_str("; ")?;
413            }
414            write!(f, "{error}")?;
415        }
416        Ok(())
417    }
418}
419
420/// Signature of a handler installed with [`set_error_handler`].
421pub type ErrorHandler = fn(&LookupError<'_>);
422
423/// The installed error handler, if any. Only read on error paths, so an unset
424/// handler costs nothing on successful lookups.
425static ERROR_HANDLER: ArcSwapOption<ErrorHandler> = ArcSwapOption::const_empty();
426
427/// Installs a process-wide handler that is called for every [`LookupError`].
428///
429/// The handler runs synchronously on the thread doing the lookup, so keep it
430/// cheap (e.g. log or bump a counter). Replaces any previous handler.
431///
432/// # Example
433///
434/// ```rust
435/// fluent_zero::set_error_handler(|error| eprintln!("i18n: {error}"));
436/// ```
437pub fn set_error_handler(handler: ErrorHandler) {
438    ERROR_HANDLER.store(Some(Arc::new(handler)));
439}
440
441/// Removes the handler installed with [`set_error_handler`].
442pub fn clear_error_handler() {
443    ERROR_HANDLER.store(None);
444}
445
446/// Delivers `error` to the installed handler and, with the `tracing` feature,
447/// to `tracing`. Kept out of line so the lookup functions stay small.
448#[cold]
449#[inline(never)]
450fn report(error: &LookupError<'_>) {
451    #[cfg(feature = "tracing")]
452    tracing::warn!(target: "fluent_zero", "{error}");
453    if let Some(handler) = &*ERROR_HANDLER.load() {
454        handler(error);
455    }
456}
457
458/// Core internal helper resolving entries efficiently, eliminating fallback duplicate closures.
459#[inline]
460fn resolve_entry<'a, B: BundleCollection + ?Sized, C: CacheStore + ?Sized>(
461    bundles: &'a B,
462    cache: &C,
463    lang: &str,
464    key: &'a str,
465    args: Option<&FluentArgs>,
466) -> Option<Cow<'a, str>> {
467    match cache.get_entry(lang, key)? {
468        CacheEntry::Static(s) => Some(Cow::Borrowed(s)),
469        CacheEntry::Dynamic => {
470            let bundle = bundles.get_bundle(lang)?;
471            // Fluent message IDs cannot contain `.`, so a dotted key always
472            // names an attribute (`message.attribute`). Only this cold path
473            // splits the key; static entries never get here.
474            let pattern = match key.split_once('.') {
475                None => {
476                    let msg = bundle.get_message(key)?;
477                    let Some(pattern) = msg.value() else {
478                        report(&LookupError::MissingValue { key, lang });
479                        return None;
480                    };
481                    pattern
482                }
483                Some((id, attribute)) => bundle.get_message(id)?.get_attribute(attribute)?.value(),
484            };
485            let mut errors = vec![];
486            let value = bundle.format_pattern(pattern, args, &mut errors);
487            if !errors.is_empty() {
488                report(&LookupError::Format {
489                    key,
490                    lang,
491                    errors: FormatErrors(&errors),
492                });
493            }
494            Some(value)
495        }
496    }
497}
498
499/// Abstracted O(1) cascade matching a language's fallback chain against established caches.
500#[inline]
501fn lookup_core<'a, B: BundleCollection + ?Sized, C: CacheStore + ?Sized>(
502    bundles: &'a B,
503    cache: &C,
504    lang: &LocaleState,
505    key: &'a str,
506    args: Option<&FluentArgs>,
507) -> Cow<'a, str> {
508    // 1. The language and its fallback chain
509    for candidate in &lang.chain {
510        if let Some(res) = resolve_entry(bundles, cache, candidate, key, args) {
511            return res;
512        }
513    }
514
515    // 2. Fallback Language
516    let fallback = cache.fallback_locale();
517    if !lang.chain.iter().any(|candidate| **candidate == *fallback)
518        && let Some(res) = resolve_entry(bundles, cache, fallback, key, args)
519    {
520        return res;
521    }
522
523    // 3. Miss
524    report(&LookupError::MissingKey {
525        key,
526        lang: &lang.key,
527    });
528    Cow::Borrowed(key)
529}
530
531/// The primary accessor macro for localized strings.
532///
533/// It looks the key up in the active language (see [`set_lang`]) using the
534/// crate's generated `CATALOG` (which carries its configured fallback locale
535/// and its locale negotiation aliases). Without arguments it returns
536/// `Cow::Borrowed` for static messages; with arguments the message is
537/// formatted with them (static messages still return without allocating).
538///
539/// # Compile-time key checking
540///
541/// A string literal key is checked at compile time against every message ID
542/// the build script found in any locale, so a typo such as `t!("app-titel")`
543/// fails the build instead of showing the raw key on screen. The check runs in
544/// a `const` item and adds nothing at run time. Keys given as any other
545/// expression (a variable, a constant such as the generated `keys::APP_TITLE`)
546/// are not checked by the macro; the generated `keys::` constants are checked
547/// by the compiler simply by existing. To pass a key that is deliberately not
548/// defined in any locale, bind it to a variable or constant first.
549///
550/// The macro resolves `crate::CATALOG` and `crate::__FLUENT_ZERO_KEYS`, so the
551/// crate calling it must `include!` the file generated by `fluent-zero-build`
552/// at its root.
553///
554/// # Examples
555///
556/// ```rust,standalone_crate
557/// # use std::sync::LazyLock;
558/// # use fluent_zero::{
559/// #     CacheEntry,
560/// #     __private::{BundleCollection, CacheStore, ConcurrentFluentBundle, FluentResource},
561/// # };
562/// # // Hand-written stand-ins for the `CATALOG` static and the key list that
563/// # // `fluent-zero-build` generates.
564/// # struct Catalog;
565/// # impl CacheStore for Catalog {
566/// #     fn get_entry(&self, lang: &str, key: &str) -> Option<CacheEntry> {
567/// #         match (lang, key) {
568/// #             ("en-US", "app-title") => Some(CacheEntry::Static("My App")),
569/// #             ("fr-FR", "app-title") => Some(CacheEntry::Static("Mon appli")),
570/// #             ("en-US", "welcome-user") => Some(CacheEntry::Dynamic),
571/// #             ("en-US", "login.tooltip") => Some(CacheEntry::Static("Sign in to your account")),
572/// #             _ => None,
573/// #         }
574/// #     }
575/// # }
576/// # static EN_US: LazyLock<ConcurrentFluentBundle<FluentResource>> = LazyLock::new(|| {
577/// #     let mut bundle = ConcurrentFluentBundle::new_concurrent(vec!["en-US".parse().unwrap()]);
578/// #     bundle.set_use_isolating(false);
579/// #     let ftl = "welcome-user = Welcome, { $name }! ({ $unread_count } unread)";
580/// #     bundle.add_resource(FluentResource::try_new(ftl.into()).unwrap()).unwrap();
581/// #     bundle
582/// # });
583/// # impl BundleCollection for Catalog {
584/// #     fn get_bundle(&self, lang: &str) -> Option<&ConcurrentFluentBundle<FluentResource>> {
585/// #         (lang == "en-US").then(|| &*EN_US)
586/// #     }
587/// # }
588/// # static CATALOG: Catalog = Catalog;
589/// # const __FLUENT_ZERO_KEYS: &[&str] = &["app-title", "login.tooltip", "welcome-user"];
590/// use fluent_zero::t;
591///
592/// # fn main() {
593/// // Basic usage:
594/// let title = t!("app-title");
595/// assert_eq!(title, "My App");
596///
597/// // With arguments:
598/// let welcome = t!("welcome-user", {
599///     "name" => "Alice",
600///     "unread_count" => 5
601/// });
602/// assert_eq!(welcome, "Welcome, Alice! (5 unread)");
603///
604/// // Attributes use the `message.attribute` key:
605/// let tooltip = t!("login.tooltip");
606/// assert_eq!(tooltip, "Sign in to your account");
607///
608/// // A missing key returns the key itself. (A literal unknown key would not
609/// // compile, so it goes through a variable.)
610/// let missing = "no-such-message";
611/// assert_eq!(t!(missing), "no-such-message");
612/// # }
613/// ```
614#[allow(clippy::crate_in_macro_def)]
615#[macro_export]
616macro_rules! t {
617    ($key:literal) => {{
618        const _: () = ::core::assert!(
619            $crate::__private::key_exists(crate::__FLUENT_ZERO_KEYS, $key),
620            "{}",
621            ::core::concat!("unknown Fluent message key passed to `t!`: ", $key)
622        );
623        $crate::__private::lookup_static(&crate::CATALOG, &crate::CATALOG, $key)
624    }};
625    ($key:literal, { $($k:expr => $v:expr),* $(,)? }) => {{
626        const _: () = ::core::assert!(
627            $crate::__private::key_exists(crate::__FLUENT_ZERO_KEYS, $key),
628            "{}",
629            ::core::concat!("unknown Fluent message key passed to `t!`: ", $key)
630        );
631        let mut args = $crate::FluentArgs::new();
632        $( args.set($k, $v); )*
633        $crate::__private::lookup_dynamic(&crate::CATALOG, &crate::CATALOG, $key, &args)
634    }};
635    ($key:expr) => {
636        $crate::__private::lookup_static(
637            &crate::CATALOG,
638            &crate::CATALOG,
639            $key
640        )
641    };
642    ($key:expr, { $($k:expr => $v:expr),* $(,)? }) => {
643        {
644            let mut args = $crate::FluentArgs::new();
645            $( args.set($k, $v); )*
646            $crate::__private::lookup_dynamic(
647                &crate::CATALOG,
648                &crate::CATALOG,
649                $key,
650                &args
651            )
652        }
653    };
654}
655
656/// Like [`t!`], but for an explicit [`LocaleState`] instead of the global
657/// language.
658///
659/// String literal keys get the same [compile-time check](t!#compile-time-key-checking)
660/// as `t!`, and misses are reported to the [error handler](set_error_handler)
661/// with the `LocaleState`'s language.
662///
663/// # Examples
664///
665/// ```rust,ignore
666/// let lang = fluent_zero::LocaleState::new("pt-BR".parse()?);
667/// let title = t_in!(&lang, "app-title");
668/// let welcome = t_in!(&lang, "welcome-user", { "name" => "Alice" });
669/// ```
670#[allow(clippy::crate_in_macro_def)]
671#[macro_export]
672macro_rules! t_in {
673    ($lang:expr, $key:literal) => {{
674        const _: () = ::core::assert!(
675            $crate::__private::key_exists(crate::__FLUENT_ZERO_KEYS, $key),
676            "{}",
677            ::core::concat!("unknown Fluent message key passed to `t_in!`: ", $key)
678        );
679        $crate::__private::lookup_static_in($lang, &crate::CATALOG, &crate::CATALOG, $key)
680    }};
681    ($lang:expr, $key:literal, { $($k:expr => $v:expr),* $(,)? }) => {{
682        const _: () = ::core::assert!(
683            $crate::__private::key_exists(crate::__FLUENT_ZERO_KEYS, $key),
684            "{}",
685            ::core::concat!("unknown Fluent message key passed to `t_in!`: ", $key)
686        );
687        let mut args = $crate::FluentArgs::new();
688        $( args.set($k, $v); )*
689        $crate::__private::lookup_dynamic_in($lang, &crate::CATALOG, &crate::CATALOG, $key, &args)
690    }};
691    ($lang:expr, $key:expr) => {
692        $crate::__private::lookup_static_in(
693            $lang,
694            &crate::CATALOG,
695            &crate::CATALOG,
696            $key
697        )
698    };
699    ($lang:expr, $key:expr, { $($k:expr => $v:expr),* $(,)? }) => {
700        {
701            let mut args = $crate::FluentArgs::new();
702            $( args.set($k, $v); )*
703            $crate::__private::lookup_dynamic_in(
704                $lang,
705                &crate::CATALOG,
706                &crate::CATALOG,
707                $key,
708                &args
709            )
710        }
711    };
712}
713
714/// Support for the macros and the code generated by `fluent-zero-build`.
715///
716/// Not part of the public API: nothing here is covered by semver.
717#[doc(hidden)]
718pub mod __private {
719    use std::{borrow::Cow, collections::HashMap, hash::BuildHasher, sync::LazyLock};
720
721    pub use fluent_bundle::{FluentResource, concurrent::FluentBundle as ConcurrentFluentBundle};
722    pub use phf;
723
724    use crate::{CacheEntry, Catalog, DEFAULT_FALLBACK, FluentArgs, LocaleState};
725
726    /// The contract version between generated code and this runtime.
727    ///
728    /// `fluent-zero-build` embeds the version it generates for into its output as
729    /// `const _: () = <crate>::__private::assert_codegen_version(N);`, so a
730    /// mismatched pair of crates fails to compile with a readable message instead
731    /// of an unresolved path. It is a plain counter, independent of the crates'
732    /// semver versions: bump it (in both crates, to the same value) whenever the
733    /// generated code starts relying on anything in this module that older
734    /// runtimes lack, or stops being accepted by newer ones.
735    pub const CODEGEN_VERSION: u32 = 1;
736
737    /// Fails const evaluation (a compile error in the calling crate) unless
738    /// `generated` equals [`CODEGEN_VERSION`].
739    ///
740    /// # Panics
741    ///
742    /// Panics when the versions differ; in the generated `const` that is a
743    /// compile-time error, never a runtime panic.
744    pub const fn assert_codegen_version(generated: u32) {
745        if generated > CODEGEN_VERSION {
746            panic!(
747                "fluent-zero is older than the fluent-zero-build that generated this code: \
748                 upgrade fluent-zero to the release matching fluent-zero-build"
749            );
750        } else if generated < CODEGEN_VERSION {
751            panic!(
752                "fluent-zero-build is older than the fluent-zero runtime it generated code for: \
753                 upgrade fluent-zero-build to the release matching fluent-zero"
754            );
755        }
756    }
757
758    /// A store that maps `(Locale, Key)` to a `CacheEntry`.
759    ///
760    /// This trait exists to abstract over the generated `phf::Map` and standard `HashMap`s
761    /// used in testing.
762    pub trait CacheStore: Sync + Send {
763        /// Retrieves a cache entry for a specific language and message key.
764        fn get_entry(&self, lang: &str, key: &str) -> Option<CacheEntry>;
765
766        /// The locale tried after every key of the language's fallback chain.
767        fn fallback_locale(&self) -> &str {
768            DEFAULT_FALLBACK
769        }
770    }
771
772    // Impl for Generated PHF Map
773    impl CacheStore for phf::Map<&'static str, &'static phf::Map<&'static str, CacheEntry>> {
774        fn get_entry(&self, lang: &str, key: &str) -> Option<CacheEntry> {
775            // Single hash on `lang` (usually very small map), then Single hash on `key`.
776            self.get(lang).and_then(|m| m.get(key)).copied()
777        }
778    }
779
780    /// A collection capable of retrieving a `ConcurrentFluentBundle` by language key.
781    pub trait BundleCollection: Sync + Send {
782        /// Retrieves the bundle for the specified language.
783        fn get_bundle(&self, lang: &str) -> Option<&ConcurrentFluentBundle<FluentResource>>;
784    }
785
786    // Impl for Generated PHF Map
787    impl BundleCollection
788        for phf::Map<&'static str, &'static LazyLock<ConcurrentFluentBundle<FluentResource>>>
789    {
790        fn get_bundle(&self, lang: &str) -> Option<&ConcurrentFluentBundle<FluentResource>> {
791            self.get(lang).map(|lazy| &***lazy)
792        }
793    }
794
795    // Impl for HashMap (For Tests)
796    impl<S: BuildHasher + Sync + Send> BundleCollection
797        for HashMap<String, ConcurrentFluentBundle<FluentResource>, S>
798    {
799        fn get_bundle(&self, lang: &str) -> Option<&ConcurrentFluentBundle<FluentResource>> {
800            self.get(lang)
801        }
802    }
803
804    /// One locale's tables in a [`Catalog`]: its static cache and its bundle.
805    pub type CatalogLocale = (
806        &'static phf::Map<&'static str, CacheEntry>,
807        &'static LazyLock<ConcurrentFluentBundle<FluentResource>>,
808    );
809
810    /// Creates a [`Catalog`]. Called by the code `fluent-zero-build` generates.
811    #[must_use]
812    pub const fn catalog(
813        fallback: &'static str,
814        locales: &'static phf::Map<&'static str, CatalogLocale>,
815    ) -> Catalog {
816        Catalog { fallback, locales }
817    }
818
819    impl CacheStore for Catalog {
820        fn get_entry(&self, lang: &str, key: &str) -> Option<CacheEntry> {
821            self.locales
822                .get(lang)
823                .and_then(|(messages, _)| messages.get(key))
824                .copied()
825        }
826
827        fn fallback_locale(&self) -> &str {
828            self.fallback
829        }
830    }
831
832    impl BundleCollection for Catalog {
833        fn get_bundle(&self, lang: &str) -> Option<&ConcurrentFluentBundle<FluentResource>> {
834            self.locales.get(lang).map(|(_, bundle)| &***bundle)
835        }
836    }
837
838    /// Retrieves a localized message without arguments, in the global language.
839    ///
840    /// This function attempts to return a `Cow::Borrowed` referencing static binary data
841    /// whenever possible to avoid allocation.
842    ///
843    /// # Resolution Order
844    ///
845    /// 1. **Current Language**: Checks each key of the current language's fallback
846    ///    chain (see [`LocaleState`]), most specific first.
847    /// 2. **Fallback Language**: If missing, checks the store's fallback locale
848    ///    ([`DEFAULT_FALLBACK`] unless the store configures one).
849    /// 3. **Missing Key**: Returns the `key` itself wrapped in `Cow::Borrowed` and
850    ///    reports a [`LookupError::MissingKey`](crate::LookupError::MissingKey) to
851    ///    the [error handler](crate::set_error_handler).
852    pub fn lookup_static<'a, B: BundleCollection + ?Sized, C: CacheStore + ?Sized>(
853        bundles: &'a B,
854        cache: &C,
855        key: &'a str,
856    ) -> Cow<'a, str> {
857        // A borrowed guard, not `get_lang()`: avoids a refcount round trip per lookup.
858        super::lookup_core(bundles, cache, &super::CURRENT_LANG.load(), key, None)
859    }
860
861    /// Retrieves a localized message with arguments, in the global language.
862    ///
863    /// Even when arguments are provided, this function checks if the underlying message
864    /// is actually static. If so, it ignores the arguments and returns the static string
865    /// to preserve performance.
866    pub fn lookup_dynamic<'a, B: BundleCollection + ?Sized, C: CacheStore + ?Sized>(
867        bundles: &'a B,
868        cache: &C,
869        key: &'a str,
870        args: &FluentArgs,
871    ) -> Cow<'a, str> {
872        super::lookup_core(bundles, cache, &super::CURRENT_LANG.load(), key, Some(args))
873    }
874
875    /// Like [`lookup_static`], but for `lang` instead of the global language.
876    pub fn lookup_static_in<'a, B: BundleCollection + ?Sized, C: CacheStore + ?Sized>(
877        lang: &LocaleState,
878        bundles: &'a B,
879        cache: &C,
880        key: &'a str,
881    ) -> Cow<'a, str> {
882        super::lookup_core(bundles, cache, lang, key, None)
883    }
884
885    /// Like [`lookup_dynamic`], but for `lang` instead of the global language.
886    pub fn lookup_dynamic_in<'a, B: BundleCollection + ?Sized, C: CacheStore + ?Sized>(
887        lang: &LocaleState,
888        bundles: &'a B,
889        cache: &C,
890        key: &'a str,
891        args: &FluentArgs,
892    ) -> Cow<'a, str> {
893        super::lookup_core(bundles, cache, lang, key, Some(args))
894    }
895
896    /// Returns whether `key` is in `sorted_keys`, which must be sorted by byte
897    /// order (as the generated `__FLUENT_ZERO_KEYS` is).
898    ///
899    /// Support for `t!`: it runs in a `const` block, so a binary search keeps the
900    /// compile-time cost of each `t!` call logarithmic in the number of keys.
901    #[must_use]
902    pub const fn key_exists(sorted_keys: &[&str], key: &str) -> bool {
903        let (mut low, mut high) = (0, sorted_keys.len());
904        while low < high {
905            let mid = low + (high - low) / 2;
906            match const_str_cmp(sorted_keys[mid], key) {
907                std::cmp::Ordering::Equal => return true,
908                std::cmp::Ordering::Less => low = mid + 1,
909                std::cmp::Ordering::Greater => high = mid,
910            }
911        }
912        false
913    }
914
915    /// `Ord for str` (byte-wise lexicographic), usable in `const` contexts.
916    const fn const_str_cmp(a: &str, b: &str) -> std::cmp::Ordering {
917        let (a, b) = (a.as_bytes(), b.as_bytes());
918        let mut i = 0;
919        while i < a.len() && i < b.len() {
920            if a[i] != b[i] {
921                return if a[i] < b[i] {
922                    std::cmp::Ordering::Less
923                } else {
924                    std::cmp::Ordering::Greater
925                };
926            }
927            i += 1;
928        }
929        if a.len() < b.len() {
930            std::cmp::Ordering::Less
931        } else if a.len() > b.len() {
932            std::cmp::Ordering::Greater
933        } else {
934            std::cmp::Ordering::Equal
935        }
936    }
937}
938
939#[cfg(test)]
940mod tests {
941    use std::collections::HashMap;
942
943    use super::{
944        __private::{
945            ConcurrentFluentBundle, FluentResource, catalog, lookup_dynamic_in, lookup_static_in,
946        },
947        *,
948    };
949
950    fn lang(tag: &str) -> LocaleState {
951        LocaleState::new(tag.parse().unwrap())
952    }
953
954    fn chain(tag: &str) -> Vec<String> {
955        lang(tag).chain().map(str::to_owned).collect()
956    }
957
958    // The only test that touches the global language, so its assertions about
959    // the default cannot race with another test's `set_lang`.
960    #[test]
961    fn global_language_defaults_to_the_fallback_and_snapshots() {
962        let id: LanguageIdentifier = DEFAULT_FALLBACK.parse().unwrap();
963        assert_eq!(id.to_string(), "en-US");
964        assert_eq!(get_lang().key(), DEFAULT_FALLBACK);
965
966        set_lang("fr-FR".parse().unwrap());
967        let state = get_lang();
968        assert_eq!(state.id(), &"fr-FR".parse::<LanguageIdentifier>().unwrap());
969        assert_eq!(state.key(), "fr-FR");
970
971        // The returned Arc is a snapshot that outlives later switches.
972        set_lang("de".parse().unwrap());
973        assert_eq!(state.key(), "fr-FR");
974        assert_eq!(get_lang().key(), "de");
975    }
976
977    #[test]
978    fn chain_drops_variants_script_and_region_in_turn() {
979        assert_eq!(chain("en-US"), ["en-US", "en"]);
980        assert_eq!(chain("ru"), ["ru"]);
981        assert_eq!(
982            chain("sr-Latn-RS"),
983            ["sr-Latn-RS", "sr-Latn", "sr-RS", "sr"]
984        );
985        assert_eq!(chain("zh-Hant"), ["zh-Hant", "zh"]);
986        assert_eq!(chain("de-DE-1996"), ["de-DE-1996", "de-DE", "de"]);
987    }
988
989    fn bundle(lang: &str, source: &str) -> ConcurrentFluentBundle<FluentResource> {
990        let mut bundle = ConcurrentFluentBundle::new_concurrent(vec![lang.parse().unwrap()]);
991        bundle.set_use_isolating(false);
992        bundle
993            .add_resource(FluentResource::try_new(source.to_string()).unwrap())
994            .unwrap();
995        bundle
996    }
997
998    static CACHE_EN_GB: phf::Map<&'static str, CacheEntry> = phf::phf_map! {
999        "colour" => CacheEntry::Static("Colour"),
1000    };
1001    static BUNDLE_EN_GB: LazyLock<ConcurrentFluentBundle<FluentResource>> =
1002        LazyLock::new(|| bundle("en-GB", "colour = Colour"));
1003    static CACHE_PT_PT: phf::Map<&'static str, CacheEntry> = phf::phf_map! {
1004        "hello" => CacheEntry::Static("Olá"),
1005        "greet" => CacheEntry::Dynamic,
1006    };
1007    static BUNDLE_PT_PT: LazyLock<ConcurrentFluentBundle<FluentResource>> =
1008        LazyLock::new(|| bundle("pt-PT", "hello = Olá\ngreet = Olá { $name }"));
1009
1010    /// What `fluent-zero-build` generates for `en-GB/` and `pt-PT/` with
1011    /// `.fallback_locale("en-GB")`: `en` and `pt` alias their only locale.
1012    static CATALOG_LOCALES: phf::Map<&'static str, CatalogLocale> = phf::phf_map! {
1013        "en-GB" => (&CACHE_EN_GB, &BUNDLE_EN_GB),
1014        "en" => (&CACHE_EN_GB, &BUNDLE_EN_GB),
1015        "pt-PT" => (&CACHE_PT_PT, &BUNDLE_PT_PT),
1016        "pt" => (&CACHE_PT_PT, &BUNDLE_PT_PT),
1017    };
1018    static CATALOG: Catalog = catalog("en-GB", &CATALOG_LOCALES);
1019
1020    fn t_in(tag: &str, key: &'static str) -> Cow<'static, str> {
1021        lookup_static_in(&lang(tag), &CATALOG, &CATALOG, key)
1022    }
1023
1024    #[test]
1025    fn region_variant_finds_the_language_locale() {
1026        // `pt-BR` -> `pt` (an alias of `pt-PT`), still zero-copy.
1027        let hello = t_in("pt-BR", "hello");
1028        assert_eq!(hello, "Olá");
1029        assert!(matches!(hello, Cow::Borrowed(_)));
1030        assert_eq!(t_in("pt", "hello"), "Olá");
1031    }
1032
1033    #[test]
1034    fn dynamic_messages_resolve_through_aliases() {
1035        let mut args = FluentArgs::new();
1036        args.set("name", "Ana");
1037        let greet = lookup_dynamic_in(&lang("pt-BR"), &CATALOG, &CATALOG, "greet", &args);
1038        assert_eq!(greet, "Olá Ana");
1039    }
1040
1041    #[test]
1042    fn configured_fallback_replaces_en_us() {
1043        assert_eq!(CATALOG.fallback(), "en-GB");
1044        assert_eq!(t_in("pt-BR", "colour"), "Colour");
1045        assert_eq!(t_in("de-DE", "colour"), "Colour");
1046        assert_eq!(t_in("de-DE", "missing"), "missing");
1047    }
1048
1049    #[test]
1050    fn catalog_lists_its_locales_and_aliases() {
1051        let mut locales: Vec<&str> = CATALOG.locales().collect();
1052        locales.sort_unstable();
1053        assert_eq!(locales, ["en", "en-GB", "pt", "pt-PT"]);
1054        assert_eq!(
1055            format!("{CATALOG:?}"),
1056            r#"Catalog { fallback: "en-GB", locales: ["en", "en-GB", "pt", "pt-PT"] }"#
1057        );
1058    }
1059
1060    #[test]
1061    fn stores_without_a_fallback_use_the_default() {
1062        struct Empty;
1063        impl CacheStore for Empty {
1064            fn get_entry(&self, _: &str, _: &str) -> Option<CacheEntry> {
1065                None
1066            }
1067        }
1068        let store: HashMap<String, ConcurrentFluentBundle<FluentResource>> = HashMap::new();
1069        assert_eq!(Empty.fallback_locale(), DEFAULT_FALLBACK);
1070        assert_eq!(lookup_static_in(&lang("fr"), &store, &Empty, "k"), "k");
1071    }
1072
1073    #[test]
1074    fn format_errors_display_and_iterate_without_exposing_their_type() {
1075        let mut errors = Vec::new();
1076        let bundle = bundle("en-US", "m = { $a } { $b }");
1077        let pattern = bundle.get_message("m").unwrap().value().unwrap();
1078        bundle.format_pattern(pattern, None, &mut errors);
1079        let errors = FormatErrors(&errors);
1080        assert_eq!(errors.len(), 2);
1081        assert!(!errors.is_empty());
1082        let messages: Vec<String> = errors.iter().map(ToString::to_string).collect();
1083        assert_eq!(errors.to_string(), messages.join("; "));
1084
1085        let error = LookupError::Format {
1086            key: "m",
1087            lang: "en-US",
1088            errors,
1089        };
1090        assert_eq!(
1091            error.to_string(),
1092            format!("failed to format message `m` (language en-US): {errors}")
1093        );
1094    }
1095
1096    #[test]
1097    fn matching_codegen_version_is_accepted() {
1098        const _: () = __private::assert_codegen_version(__private::CODEGEN_VERSION);
1099    }
1100
1101    #[test]
1102    #[should_panic(expected = "upgrade fluent-zero to the release matching fluent-zero-build")]
1103    fn newer_codegen_version_is_rejected() {
1104        __private::assert_codegen_version(__private::CODEGEN_VERSION + 1);
1105    }
1106
1107    #[test]
1108    #[should_panic(expected = "upgrade fluent-zero-build to the release matching fluent-zero")]
1109    fn older_codegen_version_is_rejected() {
1110        __private::assert_codegen_version(__private::CODEGEN_VERSION - 1);
1111    }
1112}