#![forbid(unsafe_code)]
#![warn(missing_docs)]
#![deny(rustdoc::broken_intra_doc_links)]
extern crate self as fluent_zero;
use std::{
borrow::Cow,
sync::{Arc, LazyLock},
};
use arc_swap::{ArcSwap, ArcSwapOption};
use fluent_bundle::FluentError;
pub use fluent_bundle::FluentArgs;
pub use unic_langid::LanguageIdentifier;
use crate::__private::{BundleCollection, CacheStore, CatalogLocale};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CacheEntry {
Static(&'static str),
Dynamic,
}
#[derive(Debug)]
pub struct LocaleState {
id: LanguageIdentifier,
key: String,
chain: Box<[Box<str>]>,
}
impl LocaleState {
#[must_use]
pub fn new(id: LanguageIdentifier) -> Self {
let key = id.to_string();
let language = id.language;
let (script, region) = (id.script, id.region);
let mut chain: Vec<Box<str>> = vec![key.as_str().into()];
for (script, region) in [
(script, region),
(script, None),
(None, region),
(None, None),
] {
let candidate =
LanguageIdentifier::from_parts(language, script, region, &[]).to_string();
if !chain.iter().any(|existing| **existing == *candidate) {
chain.push(candidate.into());
}
}
Self {
id,
key,
chain: chain.into_boxed_slice(),
}
}
#[must_use]
pub fn id(&self) -> &LanguageIdentifier {
&self.id
}
#[must_use]
pub fn key(&self) -> &str {
&self.key
}
pub fn chain(&self) -> impl ExactSizeIterator<Item = &str> {
self.chain.iter().map(|key| &**key)
}
}
impl From<LanguageIdentifier> for LocaleState {
fn from(id: LanguageIdentifier) -> Self {
Self::new(id)
}
}
static CURRENT_LANG: LazyLock<ArcSwap<LocaleState>> = LazyLock::new(|| {
let id: LanguageIdentifier = DEFAULT_FALLBACK.parse().unwrap_or_default();
ArcSwap::from_pointee(LocaleState::new(id))
});
pub const DEFAULT_FALLBACK: &str = "en-US";
pub fn set_lang(lang: LanguageIdentifier) {
CURRENT_LANG.store(Arc::new(LocaleState::new(lang)));
}
#[must_use]
pub fn get_lang() -> Arc<LocaleState> {
CURRENT_LANG.load_full()
}
pub struct Catalog {
fallback: &'static str,
locales: &'static __private::phf::Map<&'static str, CatalogLocale>,
}
impl Catalog {
#[must_use]
pub const fn fallback(&self) -> &'static str {
self.fallback
}
pub fn locales(&self) -> impl ExactSizeIterator<Item = &'static str> {
self.locales.keys().copied()
}
}
impl std::fmt::Debug for Catalog {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let mut locales: Vec<&str> = self.locales().collect();
locales.sort_unstable();
f.debug_struct("Catalog")
.field("fallback", &self.fallback)
.field("locales", &locales)
.finish()
}
}
#[must_use]
pub fn join_charsets(charsets: &[&str]) -> String {
let mut unique_chars = std::collections::BTreeSet::new();
for charset in charsets {
unique_chars.extend(charset.chars());
}
unique_chars.into_iter().collect()
}
#[derive(Debug, Clone, Copy, PartialEq)]
#[non_exhaustive]
pub enum LookupError<'a> {
MissingKey {
key: &'a str,
lang: &'a str,
},
MissingValue {
key: &'a str,
lang: &'a str,
},
Format {
key: &'a str,
lang: &'a str,
errors: FormatErrors<'a>,
},
}
impl std::fmt::Display for LookupError<'_> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::MissingKey { key, lang } => {
let what = if key.contains('.') {
"attribute"
} else {
"message"
};
write!(f, "missing {what} `{key}` (language {lang})")
}
Self::MissingValue { key, lang } => {
write!(f, "message `{key}` has no value (language {lang})")
}
Self::Format { key, lang, errors } => {
write!(f, "failed to format message `{key}` (language {lang})")?;
if !errors.is_empty() {
write!(f, ": {errors}")?;
}
Ok(())
}
}
}
}
impl std::error::Error for LookupError<'_> {}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct FormatErrors<'a>(&'a [FluentError]);
impl<'a> FormatErrors<'a> {
#[must_use]
pub fn len(&self) -> usize {
self.0.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
pub fn iter(&self) -> impl ExactSizeIterator<Item = &'a (dyn std::error::Error + 'static)> {
self.0
.iter()
.map(|error| error as &(dyn std::error::Error + 'static))
}
}
impl std::fmt::Display for FormatErrors<'_> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
for (i, error) in self.0.iter().enumerate() {
if i > 0 {
f.write_str("; ")?;
}
write!(f, "{error}")?;
}
Ok(())
}
}
pub type ErrorHandler = fn(&LookupError<'_>);
static ERROR_HANDLER: ArcSwapOption<ErrorHandler> = ArcSwapOption::const_empty();
pub fn set_error_handler(handler: ErrorHandler) {
ERROR_HANDLER.store(Some(Arc::new(handler)));
}
pub fn clear_error_handler() {
ERROR_HANDLER.store(None);
}
#[cold]
#[inline(never)]
fn report(error: &LookupError<'_>) {
#[cfg(feature = "tracing")]
tracing::warn!(target: "fluent_zero", "{error}");
if let Some(handler) = &*ERROR_HANDLER.load() {
handler(error);
}
}
#[inline]
fn resolve_entry<'a, B: BundleCollection + ?Sized, C: CacheStore + ?Sized>(
bundles: &'a B,
cache: &C,
lang: &str,
key: &'a str,
args: Option<&FluentArgs>,
) -> Option<Cow<'a, str>> {
match cache.get_entry(lang, key)? {
CacheEntry::Static(s) => Some(Cow::Borrowed(s)),
CacheEntry::Dynamic => {
let bundle = bundles.get_bundle(lang)?;
let pattern = match key.split_once('.') {
None => {
let msg = bundle.get_message(key)?;
let Some(pattern) = msg.value() else {
report(&LookupError::MissingValue { key, lang });
return None;
};
pattern
}
Some((id, attribute)) => bundle.get_message(id)?.get_attribute(attribute)?.value(),
};
let mut errors = vec![];
let value = bundle.format_pattern(pattern, args, &mut errors);
if !errors.is_empty() {
report(&LookupError::Format {
key,
lang,
errors: FormatErrors(&errors),
});
}
Some(value)
}
}
}
#[inline]
fn lookup_core<'a, B: BundleCollection + ?Sized, C: CacheStore + ?Sized>(
bundles: &'a B,
cache: &C,
lang: &LocaleState,
key: &'a str,
args: Option<&FluentArgs>,
) -> Cow<'a, str> {
for candidate in &lang.chain {
if let Some(res) = resolve_entry(bundles, cache, candidate, key, args) {
return res;
}
}
let fallback = cache.fallback_locale();
if !lang.chain.iter().any(|candidate| **candidate == *fallback)
&& let Some(res) = resolve_entry(bundles, cache, fallback, key, args)
{
return res;
}
report(&LookupError::MissingKey {
key,
lang: &lang.key,
});
Cow::Borrowed(key)
}
#[allow(clippy::crate_in_macro_def)]
#[macro_export]
macro_rules! t {
($key:literal) => {{
const _: () = ::core::assert!(
$crate::__private::key_exists(crate::__FLUENT_ZERO_KEYS, $key),
"{}",
::core::concat!("unknown Fluent message key passed to `t!`: ", $key)
);
$crate::__private::lookup_static(&crate::CATALOG, &crate::CATALOG, $key)
}};
($key:literal, { $($k:expr => $v:expr),* $(,)? }) => {{
const _: () = ::core::assert!(
$crate::__private::key_exists(crate::__FLUENT_ZERO_KEYS, $key),
"{}",
::core::concat!("unknown Fluent message key passed to `t!`: ", $key)
);
let mut args = $crate::FluentArgs::new();
$( args.set($k, $v); )*
$crate::__private::lookup_dynamic(&crate::CATALOG, &crate::CATALOG, $key, &args)
}};
($key:expr) => {
$crate::__private::lookup_static(
&crate::CATALOG,
&crate::CATALOG,
$key
)
};
($key:expr, { $($k:expr => $v:expr),* $(,)? }) => {
{
let mut args = $crate::FluentArgs::new();
$( args.set($k, $v); )*
$crate::__private::lookup_dynamic(
&crate::CATALOG,
&crate::CATALOG,
$key,
&args
)
}
};
}
#[allow(clippy::crate_in_macro_def)]
#[macro_export]
macro_rules! t_in {
($lang:expr, $key:literal) => {{
const _: () = ::core::assert!(
$crate::__private::key_exists(crate::__FLUENT_ZERO_KEYS, $key),
"{}",
::core::concat!("unknown Fluent message key passed to `t_in!`: ", $key)
);
$crate::__private::lookup_static_in($lang, &crate::CATALOG, &crate::CATALOG, $key)
}};
($lang:expr, $key:literal, { $($k:expr => $v:expr),* $(,)? }) => {{
const _: () = ::core::assert!(
$crate::__private::key_exists(crate::__FLUENT_ZERO_KEYS, $key),
"{}",
::core::concat!("unknown Fluent message key passed to `t_in!`: ", $key)
);
let mut args = $crate::FluentArgs::new();
$( args.set($k, $v); )*
$crate::__private::lookup_dynamic_in($lang, &crate::CATALOG, &crate::CATALOG, $key, &args)
}};
($lang:expr, $key:expr) => {
$crate::__private::lookup_static_in(
$lang,
&crate::CATALOG,
&crate::CATALOG,
$key
)
};
($lang:expr, $key:expr, { $($k:expr => $v:expr),* $(,)? }) => {
{
let mut args = $crate::FluentArgs::new();
$( args.set($k, $v); )*
$crate::__private::lookup_dynamic_in(
$lang,
&crate::CATALOG,
&crate::CATALOG,
$key,
&args
)
}
};
}
#[doc(hidden)]
pub mod __private {
use std::{borrow::Cow, collections::HashMap, hash::BuildHasher, sync::LazyLock};
pub use fluent_bundle::{FluentResource, concurrent::FluentBundle as ConcurrentFluentBundle};
pub use phf;
use crate::{CacheEntry, Catalog, DEFAULT_FALLBACK, FluentArgs, LocaleState};
pub const CODEGEN_VERSION: u32 = 1;
pub const fn assert_codegen_version(generated: u32) {
if generated > CODEGEN_VERSION {
panic!(
"fluent-zero is older than the fluent-zero-build that generated this code: \
upgrade fluent-zero to the release matching fluent-zero-build"
);
} else if generated < CODEGEN_VERSION {
panic!(
"fluent-zero-build is older than the fluent-zero runtime it generated code for: \
upgrade fluent-zero-build to the release matching fluent-zero"
);
}
}
pub trait CacheStore: Sync + Send {
fn get_entry(&self, lang: &str, key: &str) -> Option<CacheEntry>;
fn fallback_locale(&self) -> &str {
DEFAULT_FALLBACK
}
}
impl CacheStore for phf::Map<&'static str, &'static phf::Map<&'static str, CacheEntry>> {
fn get_entry(&self, lang: &str, key: &str) -> Option<CacheEntry> {
self.get(lang).and_then(|m| m.get(key)).copied()
}
}
pub trait BundleCollection: Sync + Send {
fn get_bundle(&self, lang: &str) -> Option<&ConcurrentFluentBundle<FluentResource>>;
}
impl BundleCollection
for phf::Map<&'static str, &'static LazyLock<ConcurrentFluentBundle<FluentResource>>>
{
fn get_bundle(&self, lang: &str) -> Option<&ConcurrentFluentBundle<FluentResource>> {
self.get(lang).map(|lazy| &***lazy)
}
}
impl<S: BuildHasher + Sync + Send> BundleCollection
for HashMap<String, ConcurrentFluentBundle<FluentResource>, S>
{
fn get_bundle(&self, lang: &str) -> Option<&ConcurrentFluentBundle<FluentResource>> {
self.get(lang)
}
}
pub type CatalogLocale = (
&'static phf::Map<&'static str, CacheEntry>,
&'static LazyLock<ConcurrentFluentBundle<FluentResource>>,
);
#[must_use]
pub const fn catalog(
fallback: &'static str,
locales: &'static phf::Map<&'static str, CatalogLocale>,
) -> Catalog {
Catalog { fallback, locales }
}
impl CacheStore for Catalog {
fn get_entry(&self, lang: &str, key: &str) -> Option<CacheEntry> {
self.locales
.get(lang)
.and_then(|(messages, _)| messages.get(key))
.copied()
}
fn fallback_locale(&self) -> &str {
self.fallback
}
}
impl BundleCollection for Catalog {
fn get_bundle(&self, lang: &str) -> Option<&ConcurrentFluentBundle<FluentResource>> {
self.locales.get(lang).map(|(_, bundle)| &***bundle)
}
}
pub fn lookup_static<'a, B: BundleCollection + ?Sized, C: CacheStore + ?Sized>(
bundles: &'a B,
cache: &C,
key: &'a str,
) -> Cow<'a, str> {
super::lookup_core(bundles, cache, &super::CURRENT_LANG.load(), key, None)
}
pub fn lookup_dynamic<'a, B: BundleCollection + ?Sized, C: CacheStore + ?Sized>(
bundles: &'a B,
cache: &C,
key: &'a str,
args: &FluentArgs,
) -> Cow<'a, str> {
super::lookup_core(bundles, cache, &super::CURRENT_LANG.load(), key, Some(args))
}
pub fn lookup_static_in<'a, B: BundleCollection + ?Sized, C: CacheStore + ?Sized>(
lang: &LocaleState,
bundles: &'a B,
cache: &C,
key: &'a str,
) -> Cow<'a, str> {
super::lookup_core(bundles, cache, lang, key, None)
}
pub fn lookup_dynamic_in<'a, B: BundleCollection + ?Sized, C: CacheStore + ?Sized>(
lang: &LocaleState,
bundles: &'a B,
cache: &C,
key: &'a str,
args: &FluentArgs,
) -> Cow<'a, str> {
super::lookup_core(bundles, cache, lang, key, Some(args))
}
#[must_use]
pub const fn key_exists(sorted_keys: &[&str], key: &str) -> bool {
let (mut low, mut high) = (0, sorted_keys.len());
while low < high {
let mid = low + (high - low) / 2;
match const_str_cmp(sorted_keys[mid], key) {
std::cmp::Ordering::Equal => return true,
std::cmp::Ordering::Less => low = mid + 1,
std::cmp::Ordering::Greater => high = mid,
}
}
false
}
const fn const_str_cmp(a: &str, b: &str) -> std::cmp::Ordering {
let (a, b) = (a.as_bytes(), b.as_bytes());
let mut i = 0;
while i < a.len() && i < b.len() {
if a[i] != b[i] {
return if a[i] < b[i] {
std::cmp::Ordering::Less
} else {
std::cmp::Ordering::Greater
};
}
i += 1;
}
if a.len() < b.len() {
std::cmp::Ordering::Less
} else if a.len() > b.len() {
std::cmp::Ordering::Greater
} else {
std::cmp::Ordering::Equal
}
}
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use super::{
__private::{
ConcurrentFluentBundle, FluentResource, catalog, lookup_dynamic_in, lookup_static_in,
},
*,
};
fn lang(tag: &str) -> LocaleState {
LocaleState::new(tag.parse().unwrap())
}
fn chain(tag: &str) -> Vec<String> {
lang(tag).chain().map(str::to_owned).collect()
}
#[test]
fn global_language_defaults_to_the_fallback_and_snapshots() {
let id: LanguageIdentifier = DEFAULT_FALLBACK.parse().unwrap();
assert_eq!(id.to_string(), "en-US");
assert_eq!(get_lang().key(), DEFAULT_FALLBACK);
set_lang("fr-FR".parse().unwrap());
let state = get_lang();
assert_eq!(state.id(), &"fr-FR".parse::<LanguageIdentifier>().unwrap());
assert_eq!(state.key(), "fr-FR");
set_lang("de".parse().unwrap());
assert_eq!(state.key(), "fr-FR");
assert_eq!(get_lang().key(), "de");
}
#[test]
fn chain_drops_variants_script_and_region_in_turn() {
assert_eq!(chain("en-US"), ["en-US", "en"]);
assert_eq!(chain("ru"), ["ru"]);
assert_eq!(
chain("sr-Latn-RS"),
["sr-Latn-RS", "sr-Latn", "sr-RS", "sr"]
);
assert_eq!(chain("zh-Hant"), ["zh-Hant", "zh"]);
assert_eq!(chain("de-DE-1996"), ["de-DE-1996", "de-DE", "de"]);
}
fn bundle(lang: &str, source: &str) -> ConcurrentFluentBundle<FluentResource> {
let mut bundle = ConcurrentFluentBundle::new_concurrent(vec![lang.parse().unwrap()]);
bundle.set_use_isolating(false);
bundle
.add_resource(FluentResource::try_new(source.to_string()).unwrap())
.unwrap();
bundle
}
static CACHE_EN_GB: phf::Map<&'static str, CacheEntry> = phf::phf_map! {
"colour" => CacheEntry::Static("Colour"),
};
static BUNDLE_EN_GB: LazyLock<ConcurrentFluentBundle<FluentResource>> =
LazyLock::new(|| bundle("en-GB", "colour = Colour"));
static CACHE_PT_PT: phf::Map<&'static str, CacheEntry> = phf::phf_map! {
"hello" => CacheEntry::Static("Olá"),
"greet" => CacheEntry::Dynamic,
};
static BUNDLE_PT_PT: LazyLock<ConcurrentFluentBundle<FluentResource>> =
LazyLock::new(|| bundle("pt-PT", "hello = Olá\ngreet = Olá { $name }"));
static CATALOG_LOCALES: phf::Map<&'static str, CatalogLocale> = phf::phf_map! {
"en-GB" => (&CACHE_EN_GB, &BUNDLE_EN_GB),
"en" => (&CACHE_EN_GB, &BUNDLE_EN_GB),
"pt-PT" => (&CACHE_PT_PT, &BUNDLE_PT_PT),
"pt" => (&CACHE_PT_PT, &BUNDLE_PT_PT),
};
static CATALOG: Catalog = catalog("en-GB", &CATALOG_LOCALES);
fn t_in(tag: &str, key: &'static str) -> Cow<'static, str> {
lookup_static_in(&lang(tag), &CATALOG, &CATALOG, key)
}
#[test]
fn region_variant_finds_the_language_locale() {
let hello = t_in("pt-BR", "hello");
assert_eq!(hello, "Olá");
assert!(matches!(hello, Cow::Borrowed(_)));
assert_eq!(t_in("pt", "hello"), "Olá");
}
#[test]
fn dynamic_messages_resolve_through_aliases() {
let mut args = FluentArgs::new();
args.set("name", "Ana");
let greet = lookup_dynamic_in(&lang("pt-BR"), &CATALOG, &CATALOG, "greet", &args);
assert_eq!(greet, "Olá Ana");
}
#[test]
fn configured_fallback_replaces_en_us() {
assert_eq!(CATALOG.fallback(), "en-GB");
assert_eq!(t_in("pt-BR", "colour"), "Colour");
assert_eq!(t_in("de-DE", "colour"), "Colour");
assert_eq!(t_in("de-DE", "missing"), "missing");
}
#[test]
fn catalog_lists_its_locales_and_aliases() {
let mut locales: Vec<&str> = CATALOG.locales().collect();
locales.sort_unstable();
assert_eq!(locales, ["en", "en-GB", "pt", "pt-PT"]);
assert_eq!(
format!("{CATALOG:?}"),
r#"Catalog { fallback: "en-GB", locales: ["en", "en-GB", "pt", "pt-PT"] }"#
);
}
#[test]
fn stores_without_a_fallback_use_the_default() {
struct Empty;
impl CacheStore for Empty {
fn get_entry(&self, _: &str, _: &str) -> Option<CacheEntry> {
None
}
}
let store: HashMap<String, ConcurrentFluentBundle<FluentResource>> = HashMap::new();
assert_eq!(Empty.fallback_locale(), DEFAULT_FALLBACK);
assert_eq!(lookup_static_in(&lang("fr"), &store, &Empty, "k"), "k");
}
#[test]
fn format_errors_display_and_iterate_without_exposing_their_type() {
let mut errors = Vec::new();
let bundle = bundle("en-US", "m = { $a } { $b }");
let pattern = bundle.get_message("m").unwrap().value().unwrap();
bundle.format_pattern(pattern, None, &mut errors);
let errors = FormatErrors(&errors);
assert_eq!(errors.len(), 2);
assert!(!errors.is_empty());
let messages: Vec<String> = errors.iter().map(ToString::to_string).collect();
assert_eq!(errors.to_string(), messages.join("; "));
let error = LookupError::Format {
key: "m",
lang: "en-US",
errors,
};
assert_eq!(
error.to_string(),
format!("failed to format message `m` (language en-US): {errors}")
);
}
#[test]
fn matching_codegen_version_is_accepted() {
const _: () = __private::assert_codegen_version(__private::CODEGEN_VERSION);
}
#[test]
#[should_panic(expected = "upgrade fluent-zero to the release matching fluent-zero-build")]
fn newer_codegen_version_is_rejected() {
__private::assert_codegen_version(__private::CODEGEN_VERSION + 1);
}
#[test]
#[should_panic(expected = "upgrade fluent-zero-build to the release matching fluent-zero")]
fn older_codegen_version_is_rejected() {
__private::assert_codegen_version(__private::CODEGEN_VERSION - 1);
}
}