Locales

Read BCP 47 and POSIX language tags, pick the best available locale for a user's preferences with negotiate, and ask a locale for its writing direction.

A Locale says which language, script and region to format for, plus the user’s Unicode preferences (clock, calendar, digits). Every formatter takes one. You get it from a tag with parse_locale, and from a list of the user’s preferences with negotiate.

#Reading a tag

use dxn_intl::parse_locale;

assert_eq!(parse_locale("en-US")?.to_string(), "en-US");
assert_eq!(parse_locale("sr-Latn-RS")?.to_string(), "sr-Latn-RS");
assert_eq!(parse_locale("de-CH-u-nu-latn")?.to_string(), "de-CH-u-nu-latn");
assert!(parse_locale("not a tag").is_err());

parse_locale reads BCP 47 tags: language, optional script, region and variants, then Unicode extensions after -u-. Case is normalized (EN-us reads as en-US) and surrounding whitespace is trimmed.

Locale is icu::locale::Locale, re-exported from the crate root. Its fields are public, so locale.id.language and locale.id.region give the parts, and to_string() gives the canonical tag. The ICU4X documentation lists everything else.

#POSIX tags

Platforms report locales as en_US.UTF-8 or sr_RS@latin. parse_locale accepts them: underscores become hyphens, and everything from the first . or @ on is dropped.

InputResult
en_USen-US
pt_BR.UTF-8pt-BR
zh_Hant_TW.UTF-8zh-Hant-TW
en_US.UTF-8@euroen-US
sr_RS@latinsr-RS
C, POSIX, empty texterror (InvalidLocale)

#Unicode extensions

Extensions after -u- carry the user’s preferences, and the formatters honor them:

ExtensionEffectPage
-u-nu-latnNumbering system: ar-EG shows ١٢, ar-EG-u-nu-latn shows 12Numbers
-u-hc-h12, -u-hc-h23Clock for times of dayDates and times
-u-ca-gregoryCalendar: fa is Persian by default, fa-u-ca-gregory is GregorianDates and times

#Choosing a locale

negotiate takes the user’s preferred locales in order, the locales your app has translations or support for, and a default. It returns the best match from available, or the default.

use dxn_intl::{negotiate, parse_locale};

let available = [parse_locale("en")?, parse_locale("de")?, parse_locale("hr")?];
let default = &available[0];

let pick = |tags: &[&str]| -> Result<String, dxn_intl::IntlError> {
    let requested = tags.iter().map(|t| parse_locale(t)).collect::<Result<Vec<_>, _>>()?;
    Ok(negotiate(&requested, &available, default).to_string())
};

assert_eq!(pick(&["de-AT"])?, "de");        // de-AT falls back to de
assert_eq!(pick(&["fr", "hr-BA"])?, "hr");  // fr has nothing; the next entry does
assert_eq!(pick(&["ja"])?, "en");           // no match: the default
assert_eq!(pick(&[])?, "en");

Each requested locale is tried before the next one, along CLDR’s fallback chain: de-AT then de, zh-Hant-HK then zh-Hant. An available locale matches a step when its language and script are the same and, if the step still names a region, its region too.

#Regions

At a step without a region, an available locale without a region is preferred; otherwise any region of that language matches. So a user asking for fr-FR gets fr-CA over your default when fr-CA is all you have:

use dxn_intl::{negotiate, parse_locale};

let available = [parse_locale("en")?, parse_locale("de")?, parse_locale("fr-CA")?];
let best = negotiate(&[parse_locale("fr-FR")?], &available, &available[0]);
assert_eq!(best.to_string(), "fr-CA");

let regional = [parse_locale("pt-PT")?, parse_locale("pt")?];
let best = negotiate(&[parse_locale("pt-BR")?], &regional, &available[0]);
assert_eq!(best.to_string(), "pt"); // the region-less locale wins over pt-PT

#Scripts must match

Serbian is written in Cyrillic and Latin, Chinese in Simplified and Traditional. A script mismatch is not a match, because the user could not read it:

RequestedAvailableResult
sr-Latnsr, enen (sr is Cyrillic)
sr-Cyrl-RSsr, ensr
zh-TWzh-Hant, enzh-Hant (Taiwan is Traditional)
zh-CNzh-Hant, enen

When a tag names no script, its language’s likely script is used.

#The user’s preferences survive

The Unicode extensions of the requested locale are copied onto the answer, and the rest of the requested tag (such as its region) is not:

use dxn_intl::{negotiate, parse_locale};

let available = [parse_locale("en")?, parse_locale("de")?];
let best = negotiate(&[parse_locale("de-CH-u-hc-h12")?], &available, &available[0]);
assert_eq!(best.to_string(), "de-u-hc-h12");

The formatters then show a 12-hour clock even though German defaults to 24 hours. The answer has lost the CH region, so a formatter built from it uses German, not Swiss German, conventions; if you need the user’s region for numbers or currency, format with their parsed locale and use negotiate only for choosing translations.

#Accept-Language and the system locale

The crate has no parser for an Accept-Language header and reads no environment, because it builds for the browser. Both are a few lines on your side:

use dxn_intl::{Locale, parse_locale};

/// `fr-CH, fr;q=0.9, en;q=0.8, *;q=0.5` -> [fr-CH, fr, en], in the order sent.
fn accept_language(header: &str) -> Vec<Locale> {
    let mut entries: Vec<(f32, Locale)> = header
        .split(',')
        .filter_map(|part| {
            let mut pieces = part.split(';');
            let locale = parse_locale(pieces.next()?).ok()?; // `*` fails and is skipped
            let q = pieces
                .find_map(|p| p.trim().strip_prefix("q=")?.parse::<f32>().ok())
                .unwrap_or(1.0);
            (q > 0.0).then_some((q, locale))
        })
        .collect();
    entries.sort_by(|a, b| b.0.total_cmp(&a.0)); // stable: equal weights keep their order
    entries.into_iter().map(|(_, locale)| locale).collect()
}

/// The locale from `LC_ALL` or `LANG`, or `default` for `C`, `POSIX` and unset.
fn system_locale(default: &Locale) -> Locale {
    ["LC_ALL", "LANG"]
        .iter()
        .filter_map(|name| std::env::var(name).ok())
        .find_map(|value| parse_locale(&value).ok())
        .unwrap_or_else(|| default.clone())
}

let requested = accept_language("fr-CH, fr;q=0.9, en;q=0.8, *;q=0.5");
let tags: Vec<String> = requested.iter().map(|l| l.to_string()).collect();
assert_eq!(tags, ["fr-CH", "fr", "en"]);

system_locale reads the environment, so it belongs in native code; in the browser, read navigator.languages and pass the strings to parse_locale.

#Writing direction

direction answers whether a locale’s script runs left to right or right to left, using the tag’s script or the language’s likely one.

use dxn_intl::{Direction, direction, parse_locale};

assert_eq!(direction(&parse_locale("ar")?), Direction::Rtl);
assert_eq!(direction(&parse_locale("he-IL")?), Direction::Rtl);
assert_eq!(direction(&parse_locale("az-Arab")?), Direction::Rtl); // Azerbaijani in Arabic script
assert_eq!(direction(&parse_locale("en")?), Direction::Ltr);

assert_eq!(Direction::Rtl.as_str(), "rtl"); // HTML's dir and CSS's direction

An unknown language is left to right. Direction is Copy and implements Debug, Eq and Hash, but not Display; use as_str() for text.

#Next

Plurals turns a number into a category once you have a locale. Cookbook wires these pieces into a per-locale set of formatters.