Skip to content

Repository files navigation

intl

crates.io docs.rs CI License: MIT

Pure-Rust, #![no_std] internationalization primitives — a pure-Rust analog of ICU (collation, number formatting, normalization, transliteration, …). The core Unicode algorithms ship with their official conformance suites passing 100% (Normalization, Collation, Grapheme/Word/Sentence, Line break, Bidi).

The foundational layer, available today, is the unicode module: Unicode rune analysis driven by the official Unicode Character Database (UCD), with character properties compiled directly into Rust match dispatch by an offline code generator — so every lookup is a const fn, allocates nothing, and needs no runtime initialization.

  • Everything by default — the full Unicode range, the formatters, and the character-name database are on out of the box; opt out for size.
  • Always no_std — and fully usable with no allocator (drop the default features and pick a range tier) for embedded, kernel, and WASM contexts.
  • Tables as code — the UCD is converted into a two-level paged match ("switch/case") index, not parsed at runtime.
  • Feature-selectable ranges — trim to only the slice of the codepoint space you need. Anything outside the compiled range resolves to the neutral default (Unassigned / false), so every lookup is total.
  • Targets Unicode 17.0.0.

Usage

[dependencies]
intl = "0.1"
use intl::unicode::{general_category, GeneralCategory, CharExt};

assert_eq!(general_category('A'), GeneralCategory::UppercaseLetter);
assert_eq!(general_category('中'), GeneralCategory::OtherLetter);

assert!('A'.is_uppercase());
assert!('٣'.is_numeric());          // Arabic-Indic digit three
assert!(' '.is_whitespace());
assert!(!'\u{0378}'.is_assigned()); // a reserved codepoint

Every predicate exists both as a free const fn taking a char (intl::unicode::is_uppercase('A')) and as a method via the CharExt trait ('A'.is_uppercase()).

Normalization and collation (the latter behind the alloc feature):

use intl::unicode::{nfc, nfd};
assert_eq!(nfc("e\u{0301}".chars()).collect::<String>(), "é");
assert_eq!(nfd("é".chars()).collect::<String>(), "e\u{0301}");

// With the `alloc` feature:
use intl::unicode::collate::compare;
use std::cmp::Ordering;
assert_eq!(compare("café", "cafz"), Ordering::Less); // é (≈ e) sorts before z

Beyond the unicode module:

  • intl::locale (alloc) parses and canonicalizes BCP-47 language tags (Locale::parse("zh-hant-hk") → "zh-Hant-HK"), and adds/removes likely subtags (Locale::maximize: en → en-Latn-US; Locale::minimize: zh-Hans-CN → zh), and negotiates a best match between a user's requested locales and what's available (negotiate).

  • intl::plural (no_std, no alloc) selects the CLDR PluralCategory for a number in a language — plural_category (cardinal) and ordinal_category ("1st"/"2nd"/"3rd"), rules compiled from CLDR into a match. plural_category("pl", &PluralOperands::from_int(5)) → Many. Validated against the CLDR sample data (cardinal + ordinal).

  • intl::number (alloc) formats numbers in a locale's conventions — format_decimal("de", 1234.5) → "1.234,5", format_decimal("hi", 1234567.0) → "12,34,567" (Indian grouping), format_percent("en", 0.5) → "50%", format_currency("en", 1234.5, "USD") → "$1,234.50", format_scientific ("1.2345E4"), format_compact ("1.5K", "2.3M"), and parse_decimal back to an f64 (parse_decimal("de", "1.234,5") → 1234.5), plus ranges (format_range("en", 2.9, 3.1, …) → "2.9–3.1"), numbering systems (the locale's CLDR defaultNumberingSystem by default, as ECMA-402 does — format_decimal("ar-EG", 1.5) → "١٫٥" — overridable per request with format_decimal("ar-u-nu-native", 1.5) → "١٫٥" or to_numbering_system("2024", "arab") → "٢٠٢٤") and ordinals (format_ordinal("en", 21) → "21st"). Rounding follows ECMA-402's default roundingMode: "halfExpand"; compact patterns are selected by the plural category CLDR keys them by, so fr at CompactDisplay::Long gives "mille" for 1000 but "1,5 millier" for 1500; the scientific exponent separator is the locale's (sv → "1,235×10^4"); and a currency pattern's CLDR negative subpattern places the sign where the locale puts it (nl → "US$ -1.234,50", not "-US$ 1.234,50").

  • intl::list (alloc) joins items with locale connectors, over all nine ECMA-402 type × style combinations — format_list("en", &["a","b","c"], &Default::default()) → "a, b, and c", and with list_type: Disjunction → "a, b, or c", with width: Short → "a, b, & c", with list_type: Unit, width: Narrow → "a b c".

  • intl::relative (alloc) formats relative times over all eight units in numeric × style — format_relative("en", -2.0, RelativeUnit::Hour, &Default::default()) → "2 hours ago", (1.0, Day) → "in 1 day", and with numeric: Auto → "tomorrow"; format_relative_to_parts gives the formatToParts split. Plural- and number-aware, and the sign of zero picks the direction (-0.0 → "0 days ago").

  • intl::display (no_std, no alloc) gives locale display names — language_name("fr", "de") → Some("allemand"), region_name("en", "JP") → Some("Japan").

  • intl::unit (alloc) formats measurement units — format_unit("en", 5.0, Unit::Kilometer, UnitWidth::Long) → "5 kilometers" (plural- and number-aware; long / short / narrow widths). All 45 ECMA-402 sanctioned units are covered, plus arbitrary <unit>-per-<unit> compounds: format_unit_id("en", 5.0, "meter-per-second", UnitWidth::Long) → "5 meters per second", format_unit_id("en", 5.0, "gallon-per-mile", UnitWidth::Short) → "5 gal/mi". Also durations: format_duration("en", 3661, UnitWidth::Long) → "1 hour 1 minute 1 second".

  • intl::message (alloc) is a subset of ICU MessageFormat — {arg} substitution, plural/selectordinal (with =N and #), and select, composing the plural rules and number formatting.

  • intl::datetime (alloc) formats Gregorian dates/times — format_date("en", &dt, DateStyle::Long) → "June 4, 2026", format_date("de", &dt, DateStyle::Long) → "4. Juni 2026" (CLDR patterns, month/weekday names, am/pm; weekday via Sakamoto's algorithm). Also format_skeleton("en", &dt, "yMMMd") → "Jun 4, 2026" (flexible field-set formatting), and renders Islamic (Hijri) and Persian dates with localized month names (format_islamic_date("en", 1445, 9, 1, DateStyle::Long) → "Ramadan 1, 1445 AH"; format_persian_date likewise).

  • Calendar fields, one at a time — what a Temporal/ECMA-402 layer needs to build its own formatToParts. A Calendar names the calendar by its BCP-47 -u-ca- key (Calendar::from_bcp47("islamic-umalqura")), and era_name / month_name / cyclic_year_name return that one field in that one calendar: month_name("en", Calendar::Islamic, 9, false, MonthStyle::Long) → "Ramadan", era_name("en", Calendar::Japanese, 226, NameStyle::Long) → "Kaei (1848–1854)", and the lunisolar leap-month marker (UTS #35 monthPatterns) via the leap flag — month_name("zh", Calendar::Chinese, 5, true, MonthStyle::Long) → "闰五月", "5bis" in en. Eighteen BCP-47 calendars resolve; each call returns None rather than an empty string where CLDR has no such field (the Chinese and dangi calendars have no eras at all, Coptic's only era is index 1). Needs the calendar's own cal-<key> feature (or the calendars-extra umbrella) for everything but gregory/iso8601.

  • intl::spellout spells integers out in words via the CLDR RBNF rules (locale-driven) — spell_cardinal("en", 1234) → "one thousand two hundred thirty-four", spell_cardinal("fr", 80) → "quatre-vingts", and ordinals via spell_ordinal("en", 21) → "twenty-first". (alloc)

  • intl::timezone parses a POSIX TZ string ("PST8PDT,M3.2.0,M11.1.0/2") and computes the UTC offset / DST state for any date. With the iana-tz feature it also loads the full IANA tz database (via the embedded timezone-data crate): load_zone("America/New_York") then offset_at / abbrev_at / is_dst_at / to_local for any instant, with historical transitions (the iana-tz feature, on by default).

  • Localized time-zone names (UTS #35 §4.8) drive DateTimeFormatOptions' time_zone_name: timeZoneName: "long" for America/Los_Angeles in July is "Pacific Daylight Time" in en and "heure d’été du Pacifique nord-américain" in fr; "longGeneric" is "Pacific Time", "short" is "PDT". When a locale has no name for the zone the spec's own fallback chain applies — the metazone's name, then the generic location format ("heure : Los Angeles"), then the localized GMT offset. Standard vs daylight (PST vs PDT) comes from iana-tz, or from tz_is_dst when the host already has its own tzdb and would rather not link a second copy. Not in default — the tables are large, so opt in with tz-names, or with one feature per tzdb area (tz-names-america, tz-names-europe, …) to pay only for the zones you use; see the table below.

  • intl::calendar (no_std, no alloc) converts dates between the Gregorian, civil (tabular) Islamic, Persian (Solar Hijri), Hebrew, and Chinese (lunisolar, 1900–2099 via an embedded lunar table) calendars through the Julian Day Number, gives the Japanese era/year, plus ISO-8601 week dates and day-of-week — pure integer arithmetic. DateTime also does ISO-8601 timestamp parse/format, date arithmetic (add_seconds/add_days/ weekday, leap- and carry-aware), and format_gmt_offset renders a localized UTC offset (GMT+05:30, UTC−08:00).

  • intl::translit (alloc) transliterates: latin_ascii ("café"→"cafe", "Straße"→"Strasse"), remove_diacritics, cyrillic_to_latin (ISO 9), greek_to_latin (ELOT/ISO 843), and any_ascii for best-effort mixed-script ASCII ("Москва café Αθήνα"→"Moskva cafe Athina").

These build out the CLDR/locale layer toward full ICU-style formatting. The locale data is compiled by the offline codegen into flat binary blobs committed under src/cldr/ and embedded with include_bytes!, so the table layer is no_std (no alloc dependency); only the formatting functions need alloc.

Features

Everything on by default, opt out for size. Out of the box you get the whole Unicode codepoint space (full), every allocating API (alloc), every Unicode algorithm component, the full character-name database (names), and the IANA time-zone database (iana-tz) — all no_std. MSRV 1.88.

To shrink the build, set default-features = false and enable only the range tier + the components you want.

Algorithm components (opt out individually)

Each component gates its module and its (sometimes large) generated table, so disabling one removes it from the build entirely:

feature what it provides gated table
normalization UAX #15 NFC/NFD/NFKC/NFKD 659 KB
segmentation UAX #29 grapheme/word/sentence + UAX #14 line 429 KB
bidi UAX #9 bidirectional algorithm 61 KB
case case mapping/folding (→ normalization, segmentation) 284 KB
collation UTS #10 collation (→ case, alloc) 1.9 MB
idna UTS #46 IDNA (→ normalization, alloc) 464 KB
confusables UTS #39 confusable/skeleton (→ normalization, alloc) 369 KB
identifiers UAX #31 identifiers —
names full character-name database (→ alloc) 1.3 MB

The foundational property lookups — General_Category, predicates, scripts, East Asian Width, numeric values — are always available and not gated.

# Just normalization, nothing else:
intl = { version = "0.1", default-features = false, features = ["full", "normalization"] }
# Just collation (pulls in case + normalization + alloc automatically):
intl = { version = "0.1", default-features = false, features = ["collation"] }
# Everything except the 1.9 MB collation table and IANA tz:
intl = { version = "0.1", default-features = false, features = [
    "names", "segmentation", "bidi", "case", "idna", "confusables", "identifiers",
] }

CLDR formatters (opt out individually)

The locale-aware formatters are each their own feature, gating the module and the CLDR table(s) it embeds, so a disabled formatter adds no code or data. All imply alloc (except displaynames, which is borrow-only). The always-on plural (rules) and calendar (arithmetic) modules need no feature and no data.

Most tables are .bin blobs; number, units, list, relative and tz-names are generated Rust — const fn + match lookups, except the tz-names, list and relative string tables, which are deduplicated string arenas plus index arrays (a match that wide compiles to jump tables and one code block per arm, which costs more than the strings it holds) — so their figures below are compiled footprint (.text + .rodata + .data.rel.ro) rather than blob bytes. relative grew from a 95 KB blob covering seven units in one width to 243 KB covering eight units in three; interning the strings across the corpus is what kept 3.4× the data to 2.5× the bytes. number grew ~58 KB when compact patterns gained the plural dimension CLDR keys them by: +11.5 KB of compact.bin (which stores only the categories a locale words differently, not all six — the naive cross product is +205 KB), and selecting one links the CLDR plural rules (~20 KB) that a number-only build used to leave out.

feature what it provides gated data
number decimal/percent/scientific/compact/ordinal + NumberFormat ~108 KB
number-numsys + non-latn numbering-system symbols (→ number) ~8 KB
number-range + formatRange/formatRangeToParts (→ number) ~20 KB
currency currency formatting (→ number) ~0.9 MB
units measurement units, long + short (→ number) ~905 KB
units-narrow + the narrow unit width (→ units) ~315 KB
datetime date/time/skeleton + POSIX-TZ/GMT (→ number) 219 KB
calendars-extra non-Gregorian calendar names, all 11 (→ datetime) 705 KB
displaynames Intl.DisplayNames (languages/regions) ~1.6 MB
list Intl.ListFormat, all 9 type × style 15 KB
relative Intl.RelativeTimeFormat, 8 units × 3 styles (→ number) 243 KB
message ICU MessageFormat subset (→ number) —
spellout RBNF spell-out 25 KB
transliterate script transliteration (→ normalization) —
locale BCP-47 + likely-subtags 139 KB
iana-tz full IANA tz database for named zones (→ datetime) dep
tz-names localized time-zone names, all areas (→ datetime) ~2.1 MB

Like tz-names, calendars-extra is an umbrella — 12 features over 11 calendars, so a build carries only the calendars it formats. A calendar that is not compiled in degrades to absent data rather than a wrong string: era_name / month_name / cyclic_year_name return None, and format_<cal>_date does not exist, so a missing calendar is a compile error at the call site and never a silently Gregorian result. Each calendar's names are their own locale-keyed src/cldr/cal_<key>.bin; measured text + rodata over datetime alone:

calendar feature compiled calendar feature compiled
cal-japanese-hist 334 KB cal-hebrew 38 KB
cal-dangi 77 KB cal-persian 37 KB
cal-chinese 77 KB cal-coptic 37 KB
cal-islamic 41 KB cal-japanese 19 KB
cal-indian 41 KB cal-roc 17 KB
cal-ethiopic 40 KB cal-buddhist 13 KB

Each figure includes a ~4 KB one-time dispatch shared by all of them, so a second calendar costs about its blob. cal-japanese-hist (which implies cal-japanese) is the 232 historical pre-Meiji nengō — nearly half of all the calendar data, 20× the five modern eras — and is its own feature because a build that dates present-day documents needs Reiwa/Heisei/Shōwa and nothing else. Without it a pre-Meiji date renders with the localized Gregorian era instead of its nengō, which is the fallback format_japanese_date already takes for a nengō CLDR has no name for.

calendars-extra stays in default even so. That its fallback is absent data rather than a wrong answer settles whether opting out is safe, not whether the table belongs in the default build; size settles that, and 705 KB is mid-pack here — smaller than currency, units or displaynames, all of which are in default, and a third of what keeps tz-names out.

tz-names is the one formatter feature not in default: at ~2.1 MB it is the largest single table in the crate — more than currency and units together, and ~0.55 MB gzipped in a WebAssembly build. It is an umbrella over one feature per tzdb area, so a build carries only the areas it names zones in. An area that is not compiled in falls back to the localized GMT offset — UTS #35's own last resort — so the answer stays correct, just less specific, which is what makes opting out safe.

area feature compiled area feature compiled
tz-names-america 569 KB tz-names-australia 98 KB
tz-names-asia 561 KB tz-names-indian 65 KB
tz-names-pacific 270 KB tz-names-arctic 20 KB
tz-names-europe 176 KB tz-names-etc 16 KB
tz-names-africa 131 KB
tz-names-antarctica 110 KB
tz-names-atlantic 98 KB
# Date/time with localized zone names for the Americas and Europe only:
intl = { version = "0.1", default-features = false, features = [
    "datetime", "iana-tz", "tz-names-america", "tz-names-europe",
] }
# Just number + date/time formatting (no currency, display-names, etc.):
intl = { version = "0.1", default-features = false, features = ["number", "datetime"] }
# Everything except the heavy currency + display-name data:
intl = { version = "0.1", default-features = false, features = [
    "number", "units", "datetime", "calendars-extra", "list", "relative",
    "message", "spellout", "transliterate", "locale",
] }
# Date/time with only the calendars you format (here: Hijri + Persian):
intl = { version = "0.1", default-features = false, features = [
    "datetime", "cal-islamic", "cal-persian",
] }

Range tiers (opt out for size)

The range tiers select how much of the codepoint space is compiled in, trading coverage for binary size. They are nested (each implies the smaller ones):

feature codepoints compiled
ascii U+0000..=U+007F
latin1 U+0000..=U+00FF
bmp U+0000..=U+FFFF
full U+0000..=U+10FFFF (default)
# Everything, the default:
intl = "0.1"
# Trim to the BMP and drop alloc/names for a smaller no_std build:
intl = { version = "0.1", default-features = false, features = ["bmp"] }
# Minimal: ASCII tables only:
intl = { version = "0.1", default-features = false, features = ["ascii"] }
# Unicode + alloc only — no CLDR formatters (add the ones you need, see above):
intl = { version = "0.1", default-features = false, features = ["full", "alloc"] }

A codepoint outside the compiled tier reports GeneralCategory::Unassigned (and false for every boolean predicate) — exactly as a genuinely unassigned codepoint would.

What the unicode module covers

  • General_Category (the 29 UAX #44 categories) and their major Groups, via general_category / general_category_u32.
  • Boolean predicates: is_alphabetic, is_uppercase, is_lowercase, is_whitespace (from the derived Unicode properties), plus the category-derived is_letter, is_mark, is_numeric, is_decimal_digit, is_punctuation, is_symbol, is_separator, is_control, is_format, and is_assigned; plus the property predicates is_math, is_dash, is_diacritic, is_hex_digit, is_quotation_mark, is_join_control, and is_default_ignorable.
  • Segmentation (UAX #29) — extended grapheme cluster, word, and sentence boundary iteration via graphemes(&str), words(&str), and sentences(&str) (each yielding &str, allocation-free). Grapheme breaking handles combining marks, Hangul, Indic conjuncts, regional-indicator flags, and emoji ZWJ sequences; word and sentence breaking implement the full WB / SB rule sets. All three validated against the official GraphemeBreakTest / WordBreakTest / SentenceBreakTest suites.
  • Line breaking (UAX #14) — line_breaks(&str) yielding break opportunities (mandatory vs allowed). ~99.98% conformant against LineBreakTest (a few CJK quotation/East-Asian-Width edge cases remain).
  • Collation (UTS #10) — DUCET root collation via collate::compare / collate::Collator (and sort_key), with non-ignorable or shifted variable handling, strength levels (with_strength: accent-/case-insensitive), a case level (with_case_level, UTS #10 §5.1 caseLevel — with primary strength that is "accents ignored, case significant", ECMA-402's sensitivity: "case"), numeric ordering (with_numeric: file2 < file10), and locale tailoring (Tailoring::parse("&z < å < ä < ö") / Tailoring::for_locale("sv") for primary reordering). for_locale carries the official CLDR <collation type="standard"> rule for 78 locales, generated verbatim from data/cldr/<ver>/collation/*.xml; a handful the rule engine cannot represent fall back to a hand-written approximation, and locales CLDR does not tailor at all (ga, nl, …) correctly return None and sort in root order. The BCP-47 -u-co- collation keyword selects a locale's named collation where CLDR ships one — de-u-co-phonebk (German has only a phonebook tailoring, so plain de stays root order), sv-u-co-trad, es-u-co-trad, si-u-co-dict, fi-u-co-trad, ar-u-co-compat, plus zh stroke/zhuyin/unihan under collation-zh — and falls back to the locale's standard collation when it has no collation by that name, as ICU does. collate::collations("de") enumerates the BCP-47 types a locale offers (["emoji", "eor", "phonebk"], ECMA-402's Intl.Locale.prototype.getCollations) and collate::default_collation("zh") names the one it sorts with by default ("pinyin"; "stroke" for zh-Hant / zh-TW, "default" elsewhere — resolvedOptions().collation). Both report what CLDR declares, which is a superset of what for_locale can build. Validated against the full official CollationTest suite (both modes), and each bundled rule is checked against itself by tests/collation_data_consistency. Requires the alloc feature.
  • Normalization (UAX #15) — nfd, nfc, nfkd, nfkc as streaming, allocation-free iterator adaptors over Iterator<Item = char>; quick-check helpers is_nfc/is_nfd/is_nfkc/is_nfkd (and tri-state quick_check_* → IsNormalized); plus canonical_combining_class. Validated against the full official NormalizationTest.txt conformance suite.
  • Full, unconditional case mapping — per-char to_uppercase, to_lowercase, to_titlecase, case_fold (each a CaseMapIter, 1–3 chars, e.g. ß → SS), plus whole-stream adaptors uppercase / lowercase / fold over Iterator<Item = char> (e.g. uppercase("Weiß".chars()); no allocation). fold gives caseless comparison.
  • Script and Script_Extensions (UAX #24) via script / script_u32 and script_extensions / script_extensions_u32 (Script enum with .long_name(); ScriptExtensions with .contains() / .iter()).
  • East_Asian_Width (UAX #11) via east_asian_width / east_asian_width_u32 (EastAsianWidth enum, with .is_wide()).
  • Bidirectional text (UAX #9) — bidi_class (the BidiClass enum), base_direction(&str) (rules P2–P3), and (with alloc) the full reordering algorithm bidi::process(&str, …) -> BidiInfo (embedding levels + visual order). ~99.996% conformant against BidiCharacterTest.
  • Identifiers (UAX #31) — is_xid_start, is_xid_continue, and is_identifier(&str) for default identifier validation.
  • Confusables / spoof detection (UTS #39) — spoof::skeleton, spoof::confusable, and spoof::is_single_script (mixed-script detection). Requires alloc.
  • IDNA / Punycode (UTS #46 / RFC 3492) — idna::to_ascii / idna::to_unicode for domain names (mapping + NFC + Punycode). The mapping/Punycode core passes every clean-success line of IdnaTestV2; the contextual validity rules (CheckBidi/CheckJoiners) are not yet enforced. Requires alloc.
  • Numeric_Type and exact Numeric_Value via numeric_type and numeric_value / numeric_value_u32 (NumericValue is a rational numerator / denominator, with .to_i64() / .as_f64()).
  • UNICODE_VERSION of the embedded tables.

Regenerating the tables

The committed files under src/unicode/generated/ are produced from the vendored UCD text files in data/ucd/<version>/ by the codegen tool. It is a packaging-time tool run only when updating the data or the Unicode version — the published crate never builds or invokes it, and codegen/ is a standalone package (not a workspace member and not part of intl).

cargo run --manifest-path codegen/Cargo.toml

Output is deterministic and rustfmt-clean, so regeneration with the same data yields no diff. To update the Unicode version, drop the new UCD files into data/ucd/<version>/, bump the version in codegen, and re-run.

License

MIT — see LICENSE.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages