Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 that html! escapes. Only keys ending in _html are 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_locale cookie, then Accept-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

FormMeaning
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 otherA plural message, called with count = n (any integer type or a Decimal; the absolute value is used).
a key ending in _htmlTrusted 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 &lt; 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:

LanguagesFormsExamples
ja, ko, zhotherevery count
de, en, nlone, otherone: 1 (but not 1.0)
trone, otherone: 1 and 1.0
es, it, pt-PTone, other, optional manyone: 1; many: 1000000, 2000000
fr, pt, pt-BRone, other, optional manyone: 0, 1, 1.5; many: 1000000
csone, few, many, otherfew: 2–4; many: decimals
plone, few, many, otherfew: 2–4, 22–24; many: 0, 5–21, 25
ru, ukone, few, many, otherone: 1, 21; few: 2–4, 22; many: 0, 5–20; other: decimals
roone, few, otherfew: 0, 2–19, 101; other: 20, 100
heone, two, othertwo: 2
arzero, one, two, few, many, otherfew: 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>.

MethodInputExample (pt-BR)
format_number(value)any integer or a Decimal1.234,5
format_percent(ratio)a ratio (0.25 is 25%)25%
format_currency(minor_units, currency)i64 minor units and a CurrencyR$ 1.234,56
format_date(&date, style)any chrono::Datelike22/10/2026
format_time(&time, style)any chrono::Timelike15:45
format_datetime(&value, date_style, time_style)both22/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, uk and zh (Simplified). Another tag uses its language’s data (es-MX writes like es, fr-CA like fr, zh-Hant like zh), and a language without data uses en’s; locale.format_data_tag() tells which. All of them write Latin digits and group by thousands (Indian lakh grouping is not covered; hi has no data).
  • Styles: DateStyle::Short, Medium, Long and Full are CLDR’s date formats (Short may use a two-digit year, as en’s 10/22/26 does); DateStyle::Numeric is CLDR’s day-month-year in digits, what browsers show by default. TimeStyle::Short and Medium omit the time zone, so format a DateTime already converted to the visitor’s zone.
  • Money: Currency::BRL, USD, EUR, GBP and JPY use 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 Decimal keeps its fraction digits (Decimal::new(150, 2) is 1.50; "1.50".parse() also works). round_half_even (CLDR and ICU’s default) and round_half_up (what browsers do by default) choose the digits shown.
  • Spaces: CLDR writes U+202F NARROW NO-BREAK SPACE in places such as fr grouping and before en’s AM/PM, and U+00A0 NO-BREAK SPACE in others such as pt-BR’s R$ 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.toml asks for pt-BR.toml), and a default without 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:

  1. the rullst_locale cookie, when it names a supported locale (ignoring case);
  2. the best match in the first Accept-Language header: ranges sorted by their q weight (RFC 9110), keeping the header order on ties; a range matches a supported tag exactly or by language (pt-PT and pt match pt-BR, preferring the default locale when it shares the language); * matches the default; q=0 and malformed weights are skipped. Values over 4 KiB are ignored, and only the first 32 ranges (each up to 35 bytes) are read;
  3. 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 locale or a malformed form answers 400, a body over 4 KiB answers 413, and the cookie is not changed;
  • otherwise it sets rullst_locale=<tag>; Path=/; Max-Age=31536000; SameSite=Lax; HttpOnly, plus Secure in staging and production (the same rule as the CSRF cookie);
  • it redirects with 303 See Other to next when next is 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) gets 204 with HX-Redirect instead, 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-Hant matches zh-Hans when 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.