Translations and Locales
Since 13.0.0-alpha.2, rullst::i18n translates an application’s interface
with catalogs checked at compile time, chooses each visitor’s locale from a
cookie or Accept-Language, and switches locale with a CSRF-protected form.
It is part of Core and needs no Cargo feature.
- Catalogs: one TOML file per locale, loaded by
rullst::i18n::catalog!when the crate compiles. A key missing from any locale, a key used in code that no catalog has, or placeholders that differ between locales fail the build. t!: returns plain text thathtml!escapes. Only keys ending in_htmlare trusted HTML.- Plurals: CLDR’s cardinal rules for 18 languages (Portuguese has
separate Brazilian and European rules), with every form (
zero,one,two,few,many,other) the language needs. - Negotiation: the
rullst_localecookie, thenAccept-Language, then your default; the chosen locale also feeds<html lang>. - Formatting: numbers, percentages, money and dates in the locale’s conventions, with CLDR data for 20 locales.
There is no make:i18n generator yet: the setup below is a few small files.
Set up
1. Write one catalog per locale
Create locales/ next to Cargo.toml, with one file per locale named by its
tag (language[-Script][-REGION] in canonical case: en, pt-BR, zh-Hant,
es-419). Nested tables form dotted keys.
# locales/en.toml
[nav]
home = "Home"
language = "Language"
[dashboard]
title = "Dashboard"
greeting = "Hello, {name}!"
[inbox.unread]
one = "You have one unread message"
other = "You have {count} unread messages"
[legal]
terms_html = "I accept the <a href=\"/terms\">terms</a>, {name}"
# locales/pt-BR.toml
[nav]
home = "Início"
language = "Idioma"
[dashboard]
title = "Painel"
greeting = "Olá, {name}!"
[inbox.unread]
one = "Você tem {count} mensagem não lida"
other = "Você tem {count} mensagens não lidas"
[legal]
terms_html = "Aceito os <a href=\"/terms\">termos</a>, {name}"
2. Load them in a module
// src/i18n.rs
rullst::i18n::catalog!(default = "en");
Declare it with mod i18n; in main.rs. catalog! defines two items in
that module: LOCALES, the supported locales, and the t! macro, which
other modules import with use crate::i18n::t;. Use dir = "path" for
another directory (relative to Cargo.toml).
catalog! tracks the files it read, so editing one rebuilds the crate.
Stable Rust gives a macro no way to watch a directory, so add a build.rs
next to Cargo.toml to rebuild when a locale file is added or removed:
// build.rs
fn main() {
// Use the same directory as `catalog!(dir = …)`.
println!("cargo::rerun-if-changed=locales");
}
Cargo rescans the directory before each build. Without this file, a new locale appears only after another change to the crate.
3. Negotiate, translate and switch
use crate::i18n::{LOCALES, t};
use rullst::i18n::Locale;
use rullst::security::CsrfToken;
use rullst::server::Extension;
use rullst::{HtmxRequest, html, render_page_with_lang};
async fn dashboard(
locale: Locale,
htmx: HtmxRequest,
Extension(csrf): Extension<CsrfToken>,
) -> rullst::response::Html<String> {
let switcher = html! {
<form method="post" action="/locale">
<input type="hidden" name="_token" value={csrf.as_str()} />
<input type="hidden" name="next" value="/" />
<label>{t!(locale, "nav.language")}
<select name="locale">
@for option in LOCALES.iter() {
<option value={option.tag()} selected?={option == locale}>{option.tag()}</option>
}
</select>
</label>
<button>"OK"</button>
</form>
};
let body = html! {
{switcher}
<h1>{t!(locale, "dashboard.greeting", name = "Ana")}</h1>
<p>{t!(locale, "inbox.unread", count = 3)}</p>
};
render_page_with_lang(&htmx, locale.tag(), &t!(locale, "dashboard.title"), body)
}
let app = rullst::Router::new()
.route("/", rullst::web::axum::routing::get(dashboard))
.route("/locale", LOCALES.switch_route())
.layer(LOCALES.layer())
// `Server` adds the CSRF middleware only in staging and production.
.layer(rullst::server::from_fn(rullst::security::csrf_middleware));
LOCALES.layer() negotiates every request’s locale and the Locale
extractor reads it; a handler that extracts Locale on a route without the
layer answers 500 naming the missing layer. Pass locale.tag() to
render_page_with_lang so <html lang> matches the text.
The switch route is a form POST, so the CSRF middleware checks its
_token field like any other form. Server installs that middleware only in
staging and production; compose csrf_middleware on the router as above, as
the starters do, or Extension<CsrfToken> answers 500 in development.
rullst-core/tests/i18n_catalog.rs compiles this handler’s language menu and
runs catalogs like the ones above end to end, including the switch behind the
CSRF middleware; the rullst::i18n API documentation runs the rest as a
doctest.
Catalog format
| Form | Meaning |
|---|---|
key = "text" | A message. Keys use ASCII letters, digits, _ and -; tables nest them with .. |
{name} | A placeholder: t!(locale, "key", name = value). Any Display value. Names are Rust identifiers that are not keywords. |
{{ and }} | A literal { or }. |
a table of CLDR categories with other | A plural message, called with count = n (any integer type or a Decimal; the absolute value is used). |
a key ending in _html | Trusted HTML from the catalog. t! returns Markup, and its arguments are HTML-escaped. |
t! returns Cow<'static, str>: a message without placeholders is borrowed
from the binary, others are formatted. Text from a catalog is never trusted
as HTML unless its key ends in _html, so "a < b" renders as a < b.
Plural rules
Rullst selects the form with CLDR’s cardinal plural rules (the same rules ICU and browsers use, checked against CLDR’s sample numbers). A plural message defines exactly the forms its locale’s language uses:
| Languages | Forms | Examples |
|---|---|---|
ja, ko, zh | other | every count |
de, en, nl | one, other | one: 1 (but not 1.0) |
tr | one, other | one: 1 and 1.0 |
es, it, pt-PT | one, other, optional many | one: 1; many: 1000000, 2000000 |
fr, pt, pt-BR | one, other, optional many | one: 0, 1, 1.5; many: 1000000 |
cs | one, few, many, other | few: 2–4; many: decimals |
pl | one, few, many, other | few: 2–4, 22–24; many: 0, 5–21, 25 |
ru, uk | one, few, many, other | one: 1, 21; few: 2–4, 22; many: 0, 5–20; other: decimals |
ro | one, few, other | few: 0, 2–19, 101; other: 20, 100 |
he | one, two, other | two: 2 |
ar | zero, one, two, few, many, other | few: 3–10, 103; many: 11–99 |
Regions and scripts use their language’s rule (fr-CA, es-419, zh-Hant),
except Portuguese: pt-PT has its own rule and other Portuguese regions are
not supported. many in Spanish, French, Italian and Portuguese only
applies to exact millions (“1 million de messages”); when a message leaves it
out, other is used. A missing form is a compile error that names it:
error: locales/ru.toml: plural message `inbox.unread` needs a `few` form for `ru`, which uses `one`, `few`, `many` and `other`
# locales/ru.toml
[inbox.unread]
one = "У вас {count} непрочитанное сообщение"
few = "У вас {count} непрочитанных сообщения"
many = "У вас {count} непрочитанных сообщений"
other = "У вас {count} непрочитанного сообщения"
Visible fraction digits count, as in CLDR: pass a rullst::i18n::Decimal
(Decimal::new(15, 1) is 1.5, "1.0".parse() keeps its zero) to select the
form of a decimal. {count} displays the Decimal as plain ASCII
(1.5); pass a separately formatted value for display when the locale
writes decimals differently. Without a catalog, locale.plural_category(n)
returns the PluralCategory.
#![allow(unused)]
fn main() {
use rullst::i18n::{Decimal, Locales, PluralCategory};
let locales = Locales::new(&["pl", "ar", "en"], "en")?;
let pl = locales.get("pl").unwrap();
assert_eq!(pl.plural_category(22), Some(PluralCategory::Few));
assert_eq!(pl.plural_category(25), Some(PluralCategory::Many));
assert_eq!(pl.plural_category(Decimal::new(25, 1)), Some(PluralCategory::Other));
let ar = locales.get("ar").unwrap();
assert_eq!(ar.plural_category(0), Some(PluralCategory::Zero));
assert_eq!(ar.plural_category(111), Some(PluralCategory::Many));
Ok::<(), rullst::i18n::LocaleError>(())
}
Format numbers, money and dates
A Locale formats values with CLDR’s conventions (CLDR 48 data compiled
into Rullst, no runtime files or network): decimal and grouping separators,
the minus sign, percent and currency placement, and the locale’s date and
time patterns with its month and weekday names.
#![allow(unused)]
fn main() {
use chrono::NaiveDate;
use rullst::i18n::{Currency, DateStyle, Decimal, Locales, TimeStyle};
let locales = Locales::new(&["en-US", "pt-BR", "de-DE", "fr-FR"], "en-US")?;
let [en, pt, de, fr] = ["en-US", "pt-BR", "de-DE", "fr-FR"].map(|tag| locales.get(tag).unwrap());
// Money is given in integer minor units (cents), never as a float.
assert_eq!(pt.format_currency(123_456, Currency::BRL), "R$\u{a0}1.234,56");
assert_eq!(en.format_currency(123_456, Currency::USD), "$1,234.56");
assert_eq!(de.format_currency(-123_456, Currency::EUR), "-1.234,56\u{a0}€");
assert_eq!(en.format_currency(1_234, Currency::JPY), "¥1,234");
let franc = Currency::new("CHF", 2).unwrap();
assert_eq!(de.format_currency(1_250, franc), "12,50\u{a0}CHF");
// Numbers show every digit of a `Decimal`; round explicitly.
assert_eq!(fr.format_number(1_234_567), "1\u{202f}234\u{202f}567");
assert_eq!(pt.format_number(Decimal::new(-12_345, 1)), "-1.234,5");
let ratio = Decimal::new(256, 3); // 0.256
assert_eq!(en.format_percent(ratio), "25.6%");
assert_eq!(en.format_percent(ratio.round_half_even(2)), "26%");
assert_eq!(fr.format_percent(ratio.round_half_even(2)), "26\u{a0}%");
// Dates and times, from any chrono date or time.
let at = NaiveDate::from_ymd_opt(2026, 10, 22)
.and_then(|date| date.and_hms_opt(15, 45, 0))
.unwrap();
assert_eq!(pt.format_date(&at, DateStyle::Short), "22/10/2026");
assert_eq!(en.format_date(&at, DateStyle::Short), "10/22/26");
assert_eq!(en.format_date(&at, DateStyle::Numeric), "10/22/2026");
assert_eq!(de.format_date(&at, DateStyle::Medium), "22.10.2026");
assert_eq!(pt.format_date(&at, DateStyle::Full), "quinta-feira, 22 de outubro de 2026");
assert_eq!(
en.format_datetime(&at, DateStyle::Long, TimeStyle::Short),
"October 22, 2026 at 3:45\u{202f}PM"
);
assert_eq!(pt.format_time(&at, TimeStyle::Medium), "15:45:00");
Ok::<(), rullst::i18n::LocaleError>(())
}
In a view, interpolate the result like any other text:
<td>{locale.format_currency(order.total_cents, Currency::BRL)}</td>.
| Method | Input | Example (pt-BR) |
|---|---|---|
format_number(value) | any integer or a Decimal | 1.234,5 |
format_percent(ratio) | a ratio (0.25 is 25%) | 25% |
format_currency(minor_units, currency) | i64 minor units and a Currency | R$ 1.234,56 |
format_date(&date, style) | any chrono::Datelike | 22/10/2026 |
format_time(&time, style) | any chrono::Timelike | 15:45 |
format_datetime(&value, date_style, time_style) | both | 22/10/2026, 15:45 |
- Locales with data:
ar,cs,de,en(United States),en-GB,es(Spain),fr,he,it,ja,ko,nl,pl,pt(Brazil),pt-PT,ro,ru,tr,ukandzh(Simplified). Another tag uses its language’s data (es-MXwrites likees,fr-CAlikefr,zh-Hantlikezh), and a language without data usesen’s;locale.format_data_tag()tells which. All of them write Latin digits and group by thousands (Indian lakh grouping is not covered;hihas no data). - Styles:
DateStyle::Short,Medium,LongandFullare CLDR’s date formats (Shortmay use a two-digit year, asen’s10/22/26does);DateStyle::Numericis CLDR’s day-month-year in digits, what browsers show by default.TimeStyle::ShortandMediumomit the time zone, so format aDateTimealready converted to the visitor’s zone. - Money:
Currency::BRL,USD,EUR,GBPandJPYuse each locale’s symbol (R$,US$,€,£,¥) and spacing;Currency::new(code, minor_digits)writes any other ISO code as letters. Amounts are integer minor units (i64), so no floating-point rounding happens. - Decimals: a
Decimalkeeps its fraction digits (Decimal::new(150, 2)is1.50;"1.50".parse()also works).round_half_even(CLDR and ICU’s default) andround_half_up(what browsers do by default) choose the digits shown. - Spaces: CLDR writes U+202F NARROW NO-BREAK SPACE in places such as
frgrouping and beforeen’sAM/PM, and U+00A0 NO-BREAK SPACE in others such aspt-BR’sR$ 1.234,56. Browsers write a plain space in dates and times where CLDR has U+202F.
What fails the build
- A key present in one locale and missing from another (both directions).
- A key whose placeholders differ between locales, or that is plural in one locale and single in another.
- An unbalanced
{or}, or an invalid placeholder name. - A plural message without a form its language needs (the error names it), with a form the language never selects, or in a language without a rule.
- A file name that is not a canonical tag (
pt-br.tomlasks forpt-BR.toml), and adefaultwithout a file. - In
t!: an unknown key (with a suggestion for a near miss), a missing or unexpected argument, a repeated argument, and a key that is not a string literal.
error: unknown translation key `nav.hone`; did you mean `nav.home`?
--> src/pages.rs:19:20
|
19 | let _ = t!(locale, "nav.hone");
| ^^^^^^^^^^
Keys must be literals so they can be checked. To translate a value chosen at
runtime (such as a status stored in the database), match it to keys.
Editing a catalog file rebuilds the crate, and with the build.rs from
step 2 adding or removing a locale file does too.
Negotiation
Locales::negotiate (used by the layer) picks the first of:
- the
rullst_localecookie, when it names a supported locale (ignoring case); - the best match in the first
Accept-Languageheader: ranges sorted by theirqweight (RFC 9110), keeping the header order on ties; a range matches a supported tag exactly or by language (pt-PTandptmatchpt-BR, preferring the default locale when it shares the language);*matches the default;q=0and malformed weights are skipped. Values over 4 KiB are ignored, and only the first 32 ranges (each up to 35 bytes) are read; - the default locale.
LOCALES.get(tag), LOCALES.iter() and LOCALES.default_locale() give the
supported locales, for example to build a language menu.
The switch route
LOCALES.switch_route() accepts a URL-encoded form with locale and an
optional next:
- an unsupported
localeor a malformed form answers400, a body over 4 KiB answers413, and the cookie is not changed; - otherwise it sets
rullst_locale=<tag>; Path=/; Max-Age=31536000; SameSite=Lax; HttpOnly, plusSecurein staging and production (the same rule as the CSRF cookie); - it redirects with
303 See Othertonextwhennextis a local path (it starts with a single/, has no\and only printable ASCII, up to 2 KiB), and to/otherwise, so it cannot redirect to another site; - an htmx request (
HX-Request: true) gets204withHX-Redirectinstead, so the whole page reloads in the new language.
Without a catalog
An application that keeps its own message tables can still use the
negotiation, the layer and the switch route with Locales::new:
#![allow(unused)]
fn main() {
use rullst::i18n::Locales;
use std::sync::OnceLock;
static LOCALES: OnceLock<Locales> = OnceLock::new();
let locales = Locales::new(&["en", "pt-BR"], "en")?;
assert_eq!(
locales.accept_language("pt-PT, en;q=0.8").map(|locale| locale.tag()),
Some("pt-BR")
);
// `layer()` and `switch_route()` borrow the list for the whole program; a
// `static` holds it without leaking memory.
let locales: &'static Locales = LOCALES.get_or_init(|| locales);
let _layer = locales.layer();
Ok::<(), rullst::i18n::LocaleError>(())
}
Account emails from rullst-mail keep their own languages; choose one from
the request with MailLocale::from_preference(locale.tag()).
Limits
- Cardinal plural rules only for the languages above; no ordinal rules (“1st”, “2nd”).
- Language matching ignores scripts and regions (
zh-Hantmatcheszh-Hanswhen no better tag exists). - Formatting covers the 20 locales above, Latin digits and the Gregorian
calendar; no compact (
1.2K), ordinal or spelled-out numbers, time zone names or relative times (“3 days ago”). - Catalogs are compiled in; there is no runtime catalog loading.