Project Fluent
6 min read
Fluent is Mozilla's localization system, the thing Firefox uses to speak 100+ languages. Its one big idea: grammar belongs to the translator, not the programmer.
Most i18n setups end up with code like this:
rust
format!("{} {}", count, if count == 1 { "file" } else { "files" })
That works for English and breaks everywhere else. Polish has three plural forms, Arabic has six, Japanese has one. German puts the verb somewhere else entirely. Some languages change a word depending on the gender of the person it talks about. As soon as code glues sentence pieces together, it has made grammar decisions for every language at once, and it got most of them wrong.
Fluent turns this around. The code says what to show ("the files message, with count = 5") and each translation file decides how to say it. If a language needs three plural forms, its translator writes three. English doesn't need to know or care.
Syntax guide: https://projectfluent.org/fluent/guide/ · Playground: https://projectfluent.org/play/
The Basics
Translations live in .ftl files, one per language. Each entry is just key = text:
ftl
# en/app.ftl
hello = Hello, world!
greeting = Welcome back, { $name }!
{ $name } is a variable that the code passes in. That's already enough for most of an app's strings.
Plurals
This is where Fluent shines. A selector picks a variant based on a value. For numbers, the variants are CLDR plural categories (zero, one, two, few, many, other), and * marks the default:
ftl
# en/app.ftl
files = { $count ->
[one] { $count } file
*[other] { $count } files
}
Polish needs more forms, and its translator simply writes them:
ftl
# pl/app.ftl
files = { $count ->
[one] { $count } plik
[few] { $count } pliki
*[many] { $count } plików
}
1 plik, 3 pliki, 5 plików. The code calling files with count = 5 is identical for both languages.
You can also match exact numbers, which is handy for friendlier wording:
ftl
unread = { $count ->
[0] You're all caught up
[one] One new message
*[other] { $count } new messages
}
Gender and Other Choices
Selectors work on strings too, so anything that changes grammar can be passed in and handled in the translation:
ftl
shared-photos =
{ $userName } { $photoCount ->
[one] added a new photo
*[other] added { $photoCount } new photos
} to { $userGender ->
[male] his stream
[female] her stream
*[other] their stream
}.
English needs the gender for "his/her/their". Another language might need it for the verb instead, or not at all. Each translation uses what it needs and ignores the rest.
Reusing Text: Terms
Terms start with - and are building blocks for translators. Code can't call them directly. Great for product names or anything that must stay consistent:
ftl
-brand-name = Shinra
about = About { -brand-name }
update-available = A new version of { -brand-name } is ready.
Attributes
One message can carry several related strings, e.g. everything a UI widget needs:
ftl
login-input = Email
.placeholder = you@example.com
.title = The email you signed up with
Small Gotchas in the Syntax
ftl
# Multiline text is indented
help-text =
This is the first line
and this continues it.
# Leading whitespace is trimmed, so a leading space must be a string literal
indented = {" "}text
# Same for special characters like a literal brace
braces = Use {"{"} and {"}"} for placeables.
Using It in Rust
The stack:
The best part is fl!: it checks at compile time that the key exists in the fallback language and that you passed the right arguments. A typo in a key name fails the build instead of showing a blank label to users.
Project Layout
my-app/
├── Cargo.toml
├── i18n.toml
└── i18n/
├── en/
│ └── my_app.ftl # fallback language, must be complete
└── de/
└── my_app.ftl
The file name is the crate name. i18n.toml tells the tooling where things are:
toml
fallback_language = "en"
[fluent]
assets_dir = "i18n"
Setup
rust
use std::sync::LazyLock;
use i18n_embed::{
fluent::{fluent_language_loader, FluentLanguageLoader},
DesktopLanguageRequester,
};
use rust_embed::RustEmbed;
#[derive(RustEmbed)]
#[folder = "i18n"]
struct Localizations;
pub static LOADER: LazyLock<FluentLanguageLoader> = LazyLock::new(|| {
let loader = fluent_language_loader!();
// Reads LANG / LC_MESSAGES and picks the best available match
let requested = DesktopLanguageRequester::requested_languages();
i18n_embed::select(&loader, &Localizations, &requested).unwrap();
// See "Lessons Learned" below
loader.set_use_isolating(false);
loader
});
/// Shorthand so call sites don't have to pass the loader every time
#[macro_export]
macro_rules! fl {
($id:literal) => {
i18n_embed_fl::fl!($crate::LOADER, $id)
};
($id:literal, $($args:expr),*) => {
i18n_embed_fl::fl!($crate::LOADER, $id, $($args),*)
};
}
Calling It
rust
println!("{}", fl!("hello")); // Hello, world!
println!("{}", fl!("greeting", name = "Angelo")); // Welcome back, Angelo!
println!("{}", fl!("files", count = 1)); // 1 file
println!("{}", fl!("files", count = 5)); // 5 files
// With LANG=pl_PL.UTF-8:
// 5 plików
And if you mess up:
rust
fl!("fiels", count = 5); // compile error: key doesn't exist
fl!("files"); // compile error: missing argument `count`
Lessons Learned
From adding Fluent to Shinra:
• Turn off bidi isolation. By default Fluent wraps every argument in invisible Unicode marks (U+2068 … U+2069) so right-to-left text renders correctly. That's nice for display but makes assert_eq!(fl!("files", count = 5), "5 files") fail for no visible reason. loader.set_use_isolating(false) fixes it.
• Never parse translated text back. Don't turn "42 days" into "42d" with string tricks; in German it's "42 Tage" and your parser breaks. Pass the structured value and give the catalogue a message per form:
ftl
age-long = { $days ->
[one] { $days } day
*[other] { $days } days
}
age-short = { $days }d
• Never match on display text. if title == "NEEDS A LOOK" stops working the moment someone translates it. Match on an id, enum or position instead.
• Test that translations are complete. A test can parse every .ftl file and compare its keys (and $args) against English, so a missing or stale translation fails CI:
rust
use std::collections::BTreeSet;
use fluent_syntax::{ast, parser};
fn keys(src: &str) -> BTreeSet<String> {
let res = parser::parse(src).expect("valid ftl");
res.body
.iter()
.filter_map(|e| match e {
ast::Entry::Message(m) => Some(m.id.name.to_string()),
_ => None,
})
.collect()
}
#[test]
fn translations_complete() {
let en = keys(&std::fs::read_to_string("i18n/en/my_app.ftl").unwrap());
for dir in std::fs::read_dir("i18n").unwrap() {
let path = dir.unwrap().path().join("my_app.ftl");
let other = keys(&std::fs::read_to_string(&path).unwrap());
assert_eq!(en, other, "{} differs from English", path.display());
}
}