Table of Contents
Table of Contents
- Rust i18n: Internationalization for Rust Applications
- The Rust i18n Ecosystem
- Approach 1: Fluent with fluent-bundle
- Why Fluent?
- Fluent Syntax Example
- Using fluent-bundle
- Approach 2: rust-i18n (Macro-Based)
- Setup
- Translation Files
- Using the t! Macro
- Using Locale in a Web Handler (Axum)
- ICU4X for Locale-Aware Formatting
- Number Formatting
- Plural Rules
- Handling Translation Files at Runtime vs. Compile Time
- Compile-Time Embedding (Recommended for CLI)
- Runtime Loading (Recommended for Web Services)
- WebAssembly and Rust i18n
- i18n for Rust CLIs
- Testing Rust i18n
- Choosing Between Fluent and rust-i18n
- Take your app global with better-i18n
Rust i18n: Internationalization for Rust Applications
Rust's systems programming roots might suggest it's primarily used for low-level, locale-agnostic code—but the reality is quite different. Rust powers web services, desktop applications, WebAssembly modules, and CLIs that need to serve global users in multiple languages.
The Rust i18n ecosystem is younger than many other languages but has seen significant investment, particularly around Mozilla's Fluent project and Unicode's ICU4X initiative. This guide covers the major libraries, patterns, and trade-offs for building internationalized Rust applications.
The Rust i18n Ecosystem
| Crate | Purpose |
|---|---|
fluent / fluent-bundle | Mozilla's Fluent localization system |
fluent-templates | Fluent integration for template engines |
i18n-embed | Compile-time locale embedding and runtime loading |
rust-i18n | Macro-based i18n with JSON/YAML/TOML support |
icu (ICU4X) | Unicode CLDR-based number, date, plural formatting |
unic-langid | BCP 47 language identifier parsing |
accept-language | HTTP Accept-Language header parsing |
chrono | Date/time handling (locale-agnostic) |
Approach 1: Fluent with fluent-bundle
Mozilla's Fluent is a localization system designed to address the limitations of gettext and key-value string maps. Fluent moves linguistic complexity out of your code and into the translation files, where translators can handle it.
Why Fluent?
Traditional approaches (t("key")) put linguistic decisions in the developer's hands: "do I need {count, plural, one {item} other {items}}?" Fluent moves these decisions to the translator: the translator writes the correct plural forms for their language.
Fluent also supports:
- Attribute messages (a single entity with multiple translated variants)
- Message references (reuse translated text in other messages)
- Terms (shared, non-localizable definitions like brand names)
- Selectors (conditionals based on variables, numbers, dates)
Fluent Syntax Example
# en-US/main.ftl
# Simple message
welcome = Welcome to our app!
# Message with a variable
greeting = Hello, { $name }!
# Message with plural selection
emails =
{ $count ->
[0] You have no new emails.
[one] You have one new email.
*[other] You have { $count } new emails.
}
# Message with attributes (for UI elements with label + tooltip)
submit-button =
.label = Submit
.tooltip = Click to submit your form
# Terms (brand names, non-translatable)
-brand-name = Acme Corp
referral = Thank you for using { -brand-name }!
# fr/main.ftl
welcome = Bienvenue dans notre application !
greeting = Bonjour, { $name } !
emails =
{ $count ->
[0] Vous n'avez pas de nouveaux e-mails.
[one] Vous avez un nouvel e-mail.
*[other] Vous avez { $count } nouveaux e-mails.
}
submit-button =
.label = Soumettre
.tooltip = Cliquez pour soumettre votre formulaire
referral = Merci d'utiliser { -brand-name } !
Using fluent-bundle
use fluent::{FluentBundle, FluentResource};
use fluent_langneg::negotiate_languages;
use unic_langid::LanguageIdentifier;
fn create_bundle(locale: &str, ftl_string: &str) -> FluentBundle<FluentResource> {
let langid: LanguageIdentifier = locale.parse().expect("Invalid locale");
let mut bundle = FluentBundle::new(vec![langid]);
let resource = FluentResource::try_new(ftl_string.to_string())
.expect("Failed to parse FTL");
bundle.add_resource(resource).expect("Failed to add resource");
bundle
}
fn translate_emails(bundle: &FluentBundle<FluentResource>, count: i64) -> String {
let msg = bundle.get_message("emails").expect("Message not found");
let pattern = msg.value().expect("Message has no value");
let mut args = fluent::FluentArgs::new();
args.set("count", count);
let mut errors = vec![];
let value = bundle.format_pattern(pattern, Some(&args), &mut errors);
if !errors.is_empty() {
eprintln!("Fluent errors: {:?}", errors);
}
value.into_owned()
}
fn main() {
let en_ftl = include_str!("../locales/en-US/main.ftl");
let bundle = create_bundle("en-US", en_ftl);
println!("{}", translate_emails(&bundle, 0)); // "You have no new emails."
println!("{}", translate_emails(&bundle, 1)); // "You have one new email."
println!("{}", translate_emails(&bundle, 5)); // "You have 5 new emails."
}
Approach 2: rust-i18n (Macro-Based)
For simpler applications or developers who prefer a familiar t!() macro approach, rust-i18n provides an ergonomic interface backed by YAML translation files.
Setup
# Cargo.toml
[dependencies]
rust-i18n = "3"
[package.metadata.i18n]
available-locales = ["en", "fr", "de", "ja"]
default-locale = "en"
load-path = "locales"
Translation Files
# locales/en.yaml
welcome: "Welcome!"
greeting: "Hello, %{name}!"
items:
zero: "No items"
one: "One item"
other: "%{count} items"
# locales/fr.yaml
welcome: "Bienvenue !"
greeting: "Bonjour, %{name} !"
items:
zero: "Aucun élément"
one: "Un élément"
other: "%{count} éléments"
Using the t! Macro
use rust_i18n::t;
// Initialize i18n (loads locale files at compile time)
rust_i18n::i18n!("locales");
fn main() {
// Set the active locale
rust_i18n::set_locale("fr");
// Simple translation
println!("{}", t!("welcome")); // "Bienvenue !"
// With variable interpolation
println!("{}", t!("greeting", name = "Alice")); // "Bonjour, Alice !"
// Plural handling
for count in [0, 1, 5] {
println!("{}", t!("items", count = count));
}
}
Using Locale in a Web Handler (Axum)
use axum::{
extract::{Extension, TypedHeader},
headers::AcceptLanguage,
response::Json,
};
use rust_i18n::t;
use serde_json::json;
async fn greet_handler(
TypedHeader(accept_language): TypedHeader<AcceptLanguage>,
Extension(state): Extension<AppState>,
) -> Json<serde_json::Value> {
// Parse Accept-Language and select best locale
let locale = negotiate_locale(&accept_language, &["en", "fr", "de"]);
// Set locale for this request (note: this is thread-local in rust-i18n)
rust_i18n::set_locale(&locale);
Json(json!({
"message": t!("welcome"),
"locale": locale,
}))
}
fn negotiate_locale(accept: &AcceptLanguage, supported: &[&str]) -> String {
for quality_value in accept.iter() {
let lang = quality_value.item.to_string();
let prefix = lang.split('-').next().unwrap_or(&lang);
if supported.contains(&prefix) {
return prefix.to_string();
}
}
"en".to_string()
}
ICU4X for Locale-Aware Formatting
ICU4X is a Unicode project providing CLDR-based internationalization primitives in Rust. It handles number formatting, date/time formatting, plural rules, and more—using Unicode's Common Locale Data Repository (CLDR) as its data source.
Number Formatting
use icu::decimal::FixedDecimalFormatter;
use icu::locid::locale;
use fixed_decimal::FixedDecimal;
fn format_number(amount: f64, locale_str: &str) -> String {
// Note: ICU4X supports a fixed set of compiled locales
// For dynamic locale selection, use the data provider
let locale = locale!("de"); // German locale
let fdf = FixedDecimalFormatter::try_new(
&locale.into(),
Default::default(),
).expect("locale data should be present");
let decimal = FixedDecimal::from(amount as i64);
fdf.format_to_string(&decimal)
}
Plural Rules
use icu::plurals::{PluralRules, PluralRuleType, PluralCategory};
use icu::locid::locale;
fn get_plural_category(count: f64, locale_str: &str) -> PluralCategory {
// English plural rules
let locale = locale!("en");
let pr = PluralRules::try_new(
&locale.into(),
PluralRuleType::Cardinal,
).expect("locale data should be present");
pr.category_for(FixedDecimal::from(count as i64))
}
fn translate_items(count: i64, locale: &str) -> &'static str {
let category = get_plural_category(count as f64, locale);
match (locale, category) {
("en", PluralCategory::One) => "1 item",
("en", _) if count == 0 => "No items",
("en", _) => "items", // placeholder, real app would format
_ => "items",
}
}
Handling Translation Files at Runtime vs. Compile Time
Rust gives you a choice between compile-time and runtime translation loading:
Compile-Time Embedding (Recommended for CLI)
use std::collections::HashMap;
// Embed translation files at compile time
static EN_TRANSLATIONS: &str = include_str!("../locales/en.json");
static FR_TRANSLATIONS: &str = include_str!("../locales/fr.json");
fn load_translations() -> HashMap<String, HashMap<String, String>> {
let mut translations = HashMap::new();
translations.insert(
"en".to_string(),
serde_json::from_str(EN_TRANSLATIONS).expect("Valid JSON"),
);
translations.insert(
"fr".to_string(),
serde_json::from_str(FR_TRANSLATIONS).expect("Valid JSON"),
);
translations
}
Compile-time embedding produces a single binary with no runtime dependencies on locale files. This is ideal for CLI tools and embedded applications.
Runtime Loading (Recommended for Web Services)
use std::collections::HashMap;
use std::path::Path;
fn load_translations_from_dir(dir: &Path) -> HashMap<String, HashMap<String, String>> {
let mut translations = HashMap::new();
for entry in std::fs::read_dir(dir).expect("locale dir exists") {
let entry = entry.expect("valid dir entry");
let path = entry.path();
if path.extension().map_or(false, |ext| ext == "json") {
let locale = path
.file_stem()
.expect("file has stem")
.to_string_lossy()
.to_string();
let content = std::fs::read_to_string(&path)
.expect("locale file readable");
let strings: HashMap<String, String> =
serde_json::from_str(&content).expect("valid JSON");
translations.insert(locale, strings);
}
}
translations
}
Runtime loading allows updating translations without recompiling—useful for web services where translations are updated frequently.
WebAssembly and Rust i18n
Rust compiles to WebAssembly, and Rust i18n libraries work in WASM with some constraints:
- File system access is unavailable in browser WASM environments
- Compile-time embedding (
include_str!) works well for WASM - ICU4X is specifically designed to work in constrained environments including WASM
#[cfg(target_arch = "wasm32")]
mod wasm {
use wasm_bindgen::prelude::*;
use rust_i18n::t;
rust_i18n::i18n!("locales");
#[wasm_bindgen]
pub fn translate(key: &str, locale: &str) -> String {
rust_i18n::set_locale(locale);
t!(key)
}
}
i18n for Rust CLIs
CLI applications detect system locale from environment variables:
use std::env;
pub fn detect_system_locale() -> String {
// Priority: LANG > LC_ALL > LC_MESSAGES > default
for var in &["LANG", "LC_ALL", "LC_MESSAGES"] {
if let Ok(val) = env::var(var) {
// LANG is typically "en_US.UTF-8"
let locale = val
.split('.') // Remove encoding suffix
.next()
.unwrap_or("en")
.replace('_', "-"); // Convert to BCP 47 (en_US → en-US)
if !locale.is_empty() {
return locale;
}
}
}
// Windows: use GetUserDefaultLocaleName via windows crate
#[cfg(target_os = "windows")]
{
// ... Windows locale detection
}
"en".to_string()
}
Testing Rust i18n
#[cfg(test)]
mod tests {
use rust_i18n::t;
#[test]
fn test_english_plurals() {
rust_i18n::set_locale("en");
// Test all plural forms
assert_eq!(t!("items", count = 0), "No items");
assert_eq!(t!("items", count = 1), "One item");
assert_eq!(t!("items", count = 5), "5 items");
}
#[test]
fn test_french_translation() {
rust_i18n::set_locale("fr");
assert_eq!(t!("welcome"), "Bienvenue !");
assert_eq!(t!("greeting", name = "Marie"), "Bonjour, Marie !");
}
#[test]
fn test_fallback_to_english() {
// Locale with no translation file falls back to English
rust_i18n::set_locale("xx");
assert_eq!(t!("welcome"), "Welcome!"); // English fallback
}
}
For comprehensive testing strategies applicable to Rust, see i18n testing tools, strategies, and automation.
Choosing Between Fluent and rust-i18n
| Criterion | Fluent | rust-i18n |
|---|---|---|
| Linguistic expressiveness | High | Medium |
| Translator-friendly | Very | Moderate |
| Developer ergonomics | More verbose | Simple macros |
| Plural handling | Native in FTL | Key suffixes |
| Community adoption | Growing | More adoption |
| Best for | Complex apps, community translation | Simpler apps, developer teams |
For applications with complex linguistic needs or large translator communities, Fluent's expressiveness is worth the additional setup. For simpler apps or developer-managed translations, rust-i18n's ergonomics win.
Take your app global with better-i18n
better-i18n combines AI-powered translations, git-native workflows, and global CDN delivery into one developer-first platform. Stop managing spreadsheets and start shipping in every language.