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}