Merge remote-tracking branch 'origin/i18n-format' into i18n-parse-number

# Conflicts:
#	crates/i18n/src/lib.rs
This commit is contained in:
aiongg committed 2026-10-21 05:54:08 +00:00
commit 217f108ee0
11 files changed
+1661 -71

No files matched your search

Generated
+143 -29
View File
@@ -491,6 +491,15 @@ dependencies = [
"x509-cert",
]
[[package]]
name = "cobs"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1"
dependencies = [
"thiserror 2.0.21",
]
[[package]]
name = "codespan-reporting"
version = "0.13.1"
@@ -656,9 +665,18 @@ version = "0.1.0"
name = "craft-i18n"
version = "0.1.0"
dependencies = [
"intl_pluralrules",
"fixed_decimal",
"icu_decimal",
"icu_locale_core",
"icu_locale_fallback",
"icu_plurals",
"icu_provider",
"icu_provider_adapters",
"icu_provider_blob",
"objc2-foundation 0.3.2",
"sys-locale",
"unic-langid",
"windows-registry",
"writeable",
]
[[package]]
@@ -1230,6 +1248,17 @@ version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484"
[[package]]
name = "fixed_decimal"
version = "0.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "79c3c892f121fff406e5dd6b28c1b30096b95111c30701a899d4f2b18da6d1bd"
dependencies = [
"displaydoc",
"smallvec",
"writeable",
]
[[package]]
name = "flagset"
version = "0.4.7"
@@ -1859,6 +1888,23 @@ dependencies = [
"zerovec",
]
[[package]]
name = "icu_decimal"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9eb8f655bba2d0c0459e43a5b0e6cd3cd388109898fb3d85d048165e8b0abd08"
dependencies = [
"displaydoc",
"fixed_decimal",
"icu_locale_core",
"icu_pattern",
"icu_plurals",
"icu_provider",
"serde",
"writeable",
"zerovec",
]
[[package]]
name = "icu_locale_core"
version = "2.3.0"
@@ -1867,11 +1913,32 @@ checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb"
dependencies = [
"displaydoc",
"litemap",
"serde",
"tinystr",
"writeable",
"zerovec",
]
[[package]]
name = "icu_locale_fallback"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "251af8e57c9400e3eb58242fe5b8b1152b2a64fdf4cf632f923c38ccee6f2fa9"
dependencies = [
"icu_locale_core",
"icu_locale_fallback_data",
"icu_provider",
"potential_utf",
"tinystr",
"zerovec",
]
[[package]]
name = "icu_locale_fallback_data"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "decf2a22ec8fa68f1a0c1129a3f8583f8f8bc24e8b9ccbe98ead99f62a4dc3a8"
[[package]]
name = "icu_normalizer"
version = "2.3.0"
@@ -1892,6 +1959,32 @@ version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0"
[[package]]
name = "icu_pattern"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b3db611446bd92c246b03e4aa0bafa71ddb194af431ddbb42b9d70e4bfce1a0"
dependencies = [
"displaydoc",
"either",
"serde",
"writeable",
"zerovec",
]
[[package]]
name = "icu_plurals"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e475e6766ef87b1d3c1f97be6363995b0bfaeddbc789671615df598a97ff0593"
dependencies = [
"displaydoc",
"fixed_decimal",
"icu_provider",
"serde",
"zerovec",
]
[[package]]
name = "icu_properties"
version = "2.3.0"
@@ -1921,6 +2014,9 @@ checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73"
dependencies = [
"displaydoc",
"icu_locale_core",
"postcard",
"serde",
"stable_deref_trait",
"writeable",
"yoke",
"zerofrom",
@@ -1928,6 +2024,30 @@ dependencies = [
"zerovec",
]
[[package]]
name = "icu_provider_adapters"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df887bc4f55dbc773c7c45a0c7dfa75be886f7b7812830a71ab7271cc1801820"
dependencies = [
"icu_locale_fallback",
"icu_provider",
]
[[package]]
name = "icu_provider_blob"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5916b44fac319d3f58e4d8d805a627df19e27937b582cd99fcde3bb6b7ce1b0e"
dependencies = [
"icu_provider",
"postcard",
"serde",
"writeable",
"zerotrie",
"zerovec",
]
[[package]]
name = "idna"
version = "1.1.0"
@@ -1995,15 +2115,6 @@ dependencies = [
"hashbrown 0.17.1",
]
[[package]]
name = "intl_pluralrules"
version = "7.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "078ea7b7c29a2b4df841a7f6ac8775ff6074020c6776d48491ce2268e068f972"
dependencies = [
"unic-langid",
]
[[package]]
name = "ipnet"
version = "2.12.2"
@@ -2248,6 +2359,9 @@ name = "litemap"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"
dependencies = [
"serde_core",
]
[[package]]
name = "litrs"
@@ -3079,12 +3193,24 @@ dependencies = [
"portable-atomic",
]
[[package]]
name = "postcard"
version = "1.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24"
dependencies = [
"cobs",
"serde",
]
[[package]]
name = "potential_utf"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661"
dependencies = [
"serde_core",
"writeable",
"zerovec",
]
@@ -4715,24 +4841,6 @@ version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94"
[[package]]
name = "unic-langid"
version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a28ba52c9b05311f4f6e62d5d9d46f094bd6e84cb8df7b3ef952748d752a7d05"
dependencies = [
"unic-langid-impl",
]
[[package]]
name = "unic-langid-impl"
version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dce1bf08044d4b7a94028c93786f8566047edc11110595914de93362559bc658"
dependencies = [
"tinystr",
]
[[package]]
name = "unicode-bidi"
version = "0.3.18"
@@ -5792,6 +5900,9 @@ name = "writeable"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc"
dependencies = [
"either",
]
[[package]]
name = "x11rb"
@@ -5985,8 +6096,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f"
dependencies = [
"displaydoc",
"litemap",
"serde_core",
"yoke",
"zerofrom",
"zerovec",
]
[[package]]
+20 -3
View File
@@ -1,6 +1,6 @@
[package]
name = "craft-i18n"
description = "Translations for the Craft apps: the embedded craft-i18n catalogs, lookup with fallback, plurals, language names and the system locale."
description = "Translations for the Craft apps: the embedded craft-i18n catalogs, lookup with fallback, plurals, numbers for a region, language names and the system locale."
version.workspace = true
edition.workspace = true
license.workspace = true
@@ -8,12 +8,29 @@ rust-version.workspace = true
repository.workspace = true
[dependencies]
intl_pluralrules = "=7.0.2"
unic-langid = "0.9"
# Numbers and plural rules, on CLDR data (data/numbers.postcard) generated for one ICU4X minor
# version, hence `~2.3`.
fixed_decimal = { version = "0.7.2", default-features = false }
icu_decimal = { version = "~2.3", default-features = false, features = ["serde"] }
icu_locale_core = { version = "~2.3", default-features = false }
icu_locale_fallback = { version = "~2.3", default-features = false, features = ["compiled_data"] }
icu_plurals = { version = "~2.3", default-features = false, features = ["serde"] }
icu_provider = { version = "~2.3", default-features = false, features = ["sync"] }
icu_provider_adapters = { version = "~2.3", default-features = false }
icu_provider_blob = { version = "~2.3", default-features = false }
writeable = "0.6"
sys-locale = "0.3"
[target.'cfg(target_arch = "wasm32")'.dependencies]
sys-locale = { version = "0.3", features = ["js"] }
# The regional format (Region::system); safe registry reads, MIT OR Apache-2.0.
[target.'cfg(windows)'.dependencies]
windows-registry = "0.6"
# The current locale (Region::system); already in the apps' Cargo.lock through eframe.
[target.'cfg(target_vendor = "apple")'.dependencies]
objc2-foundation = { version = "0.3.2", default-features = false, features = ["std", "NSDateFormatter", "NSFormatter", "NSLocale", "NSString"] }
[lints]
workspace = true
+43 -6
View File
@@ -45,8 +45,8 @@ The app keeps its `tl!` macro, its language setting (`"auto"` → `I18N.system_l
→ `pt`, `zh-hant-hk` → `zh-hant`); `sr-latn` never falls back to Cyrillic `sr`. A missing
translation never errors.
- **Lookups:** `t` (plain), `tc` (with a context such as `menu` or `@msg`, else plain), `tid` (an
`@id` command id, else the English label), `trn` (a count, `@plural` rows, `{n}` filled), `get`
(one context along the locale chain, so `pt-br` can return a `pt` row, but never the plain row
`@id` command id, else the English label), `trn` and `trn_decimal` (a count, `@plural` rows,
`{n}` filled), `get` (one context along the locale chain, so `pt-br` can return a `pt` row, but never the plain row
or English: a key cap's name under `key`, or "is there a translation?"), and `fmt` for `{name}`
placeholders, filled in one pass.
- **Bad rows** (malformed, an unknown `@` context, a wrong plural form count, a row defined twice,
@@ -54,10 +54,13 @@ The app keeps its `tl!` macro, its language setting (`"auto"` → `I18N.system_l
are skipped and returned by `I18n::new` for the app to log; craft-i18n's CI rejects them before
they ship.
- **Plurals:** forms are listed in CLDR order (zero, one, two, few, many, other) for the categories
whole numbers 0 to 1000 reach, from `intl_pluralrules`; a count in any other category takes the
last form. CLDR's `pt` is Brazilian, so `pt` uses `pt-PT`'s rules (0 is plural) and `pt-br`
`pt`'s. `intl_pluralrules` is pinned to the version craft-i18n's checker uses, and
`plural_form_counts` holds the same counts.
whole numbers 0 to 1000 reach, from ICU4X's CLDR rules; `plural_form_counts` holds the counts
craft-i18n's checker expects. `trn_decimal` picks the form from the digits shown (ICU4X plural
operands), so English `1.0` is plural and French `1,5` singular. A count in a category integers
never reach takes the `few` form if there is one (Russian and Ukrainian decimals, `1,5 файла`),
else the last. Rules are per language, so `sr-latn` and `zh-hant` take `sr`'s and `zh`'s. CLDR's
`pt` is Brazilian, so `pt` uses `pt-PT`'s rules (0 is plural) and `pt-br` `pt`'s. A language
CLDR has no plural rules for shows English forms.
- **Names:** a language's name comes from its shared catalog's `@id language.name` row, else its
code; English is "English".
- **Right to left:** `rtl` is true for `ar`, `ckb`, `dv`, `fa`, `he`, `ps`, `sd`, `ug`, `ur` and
@@ -74,6 +77,40 @@ The app keeps its `tl!` macro, its language setting (`"auto"` → `I18N.system_l
`zh-hant`, `pt-PT` → `pt-br` when that is the only Portuguese, `C`/`POSIX` → English. A script
is never crossed: `sr_RS@latin` and `sr-Latn-RS` pick `sr-latn`, never `sr`, while `sr-Cyrl-RS`
picks `sr`.
- **Sizes and units:** `file_size(lang, bytes, style)` writes `1.5 MB` or `512 bytes`; `FileSize`
picks steps of 1024 named KB (`BINARY`), 1024 named KiB (`IEC`) or 1000 named KB (`DECIMAL`),
the decimals per unit, no decimals from a given value up, the smallest and largest unit (below
the smallest, sizes round up), and `B` or words for bytes. `measure(lang, value, decimals,
"{n} px")` writes any other measure. Unit names are rows in context `unit`, keyed by the English
template (`{n} MB` → `{n} Mo`, `{n}%` → `{n} %`) and read with `get`, so a plain `B` row (bold)
is never used; bytes in words are the `@plural` row `{n} byte|{n} bytes`. Numbers are written
by `I18n::format`.
- **Numbers:** `Format` writes numbers for a `Region` on ICU4X's decimal formatter: `integer`
(`1.234.567`, Indian `12,34,567`), `number(value, decimals)` and `percent(ratio, decimals)`
(`50 %`, Turkish `%50`). It follows the region, not the interface language, with the locale's
digits (`١٬٢٣٤٫٥` in Egypt, `۱٬۲۳۴` in Iran) or, with `Digits::Latin`, `0`–`9`. With Latin
digits, a decimal or grouping separator the system sets (Windows' and macOS's own settings)
replaces the locale's. `I18n::format` is the format `trn`, `file_size` and `measure` use: the
system region's until `I18n::set_format` (the user's region setting or digit choice), and again
after `I18n::reset_format`. A count in `trn` is therefore grouped and in the region's digits
(`1.234 Dateien`, `١٬٢٣٤ ملفًا`); for names written into documents or files (`Copy 1234`), use
`trn_plain`, which writes `0`–`9` without grouping.
- **CLDR data:** decimal symbols, digits and plural rules (`data/numbers.postcard`, about 10 KB) and
percent patterns (`data/locales.rs`) cover every CLDR locale; `data/generate.sh` rebuilds both
from CLDR. The ICU4X crates are pinned to one minor version (`~2.3`), the one the data is
generated for. The ICU4X code and data add about 90 KB to a stripped release build and 80 KB to
wasm.
- **Formatting region:** `Region` holds how numbers, times and measurements are written, apart
from the interface language: the number and time locales (BCP 47), metric or US measurement, A4
or Letter, and the decimal and grouping separators (or no grouping) and clock when the system
sets them; otherwise the number locale's CLDR data decides. `Region::system()` reads it once:
Windows' regional format with the user's changes (`HKCU\Control Panel\International`), Apple's
current locale (`en_PL`, `en_GB@rg=uszzzz`), `LC_ALL` or else `LC_NUMERIC`, `LC_TIME`,
`LC_MEASUREMENT` and `LC_PAPER` each on its own over `LANG` on Unix (`C` there is `en-US`;
`LANG=C` gives way to the preferred language), the preferred language on the web and Android.
`Region::from_setting` takes the app's saved choice (`auto` or a tag like `pl-PL`). Paper and
measurement follow CLDR's territory lists; tags may carry `-u-ms-`, `-u-hc-` and `-u-rg-`.
Ported from WordCraft's `Region`.
- **Pseudo locale** (`pseudo`, debug builds only, never in `languages()`): every lookup, `get`
included, comes back as `⟦…⟧`, line by line, so text drawn without the marks never went through
translation. `looked_up()` lists the source texts looked up so far, for an audit against a
+22
View File
@@ -0,0 +1,22 @@
#!/usr/bin/env bash
# Regenerates the number and plural data in this directory from Unicode CLDR, for every CLDR
# locale: 10 KB, of which CLDR's modern-coverage locales alone would be 7 KB. Lookups fall back
# at run time, so a locale whose data matches its parent's is left out.
#
# Needs `cargo install icu4x-datagen --version 2.3.0` (the ICU4X minor version in Cargo.toml)
# and python3. Downloads CLDR's JSON release once into $CACHE.
set -euo pipefail
CLDR=48.2.1
HERE=$(cd "$(dirname "$0")" && pwd)
CACHE=${CACHE:-${XDG_CACHE_HOME:-$HOME/.cache}/craft-i18n-data}
mkdir -p "$CACHE"
TMPDIR=$CACHE icu4x-datagen --format blob --overwrite \
--cldr-tag "$CLDR" \
--markers DecimalSymbolsV1 DecimalDigitsV1 PluralsCardinalV1 \
--locales full --deduplication maximal \
--out "$HERE/numbers.postcard"
ZIP=$CACHE/icu4x-source-cache/github.com/unicode-org/cldr-json/releases/download/$CLDR/cldr-$CLDR-json-full.zip
python3 -I "$HERE/locales.py" "$ZIP" "$CLDR" > "$HERE/locales.rs"
+70
View File
@@ -0,0 +1,70 @@
"""Writes locales.rs from CLDR's JSON release: the languages with plural rules and every locale's
percent affixes.
Usage: locales.py <cldr-json-full.zip> <cldr version>
"""
import json
import re
import sys
import zipfile
def rust(s):
return '"' + "".join(c if " " <= c <= "~" and c not in '"\\' else f"\\u{{{ord(c):x}}}" for c in s) + '"'
def affixes(numbers, numsys):
pattern = numbers[f"percentFormats-numberSystem-{numsys}"]["standard"].split(";")[0].replace("'", "")
sign = numbers[f"symbols-numberSystem-{numsys}"]["percentSign"]
number = re.search(r"[#0,.]+", pattern)
return pattern[: number.start()].replace("%", sign), pattern[number.end() :].replace("%", sign)
def main():
path, version = sys.argv[1], sys.argv[2]
found = {}
with zipfile.ZipFile(path) as z:
plurals = json.loads(z.read("cldr-core/supplemental/plurals.json"))["supplemental"]["plurals-type-cardinal"]
languages = sorted(l for l in plurals if l != "root" and "-" not in l)
for name in z.namelist():
m = re.fullmatch(r"cldr-numbers-full/main/([^/]+)/numbers\.json", name)
if not m or m.group(1) == "root":
continue
locale = m.group(1)
numbers = json.loads(z.read(name))["main"][locale]["numbers"]
found[(locale, True)] = affixes(numbers, "latn")
default = numbers["defaultNumberingSystem"]
if default != "latn":
found[(locale, False)] = affixes(numbers, default)
# Kept only where the lookup by dropping trailing subtags would find something else.
kept = {}
for (locale, latin), value in sorted(found.items(), key=lambda e: (e[0][0].count("-"), e[0])):
parts = locale.split("-")
inherited = ("", "%")
for n in range(len(parts) - 1, 0, -1):
if ("-".join(parts[:n]), latin) in kept:
inherited = kept[("-".join(parts[:n]), latin)]
break
if value != inherited:
kept[(locale, latin)] = value
print(f"// @generated by generate.sh from Unicode CLDR {version} (Unicode License).")
print()
print("/// The languages CLDR has plural rules for; the baked data has them all.")
print("pub(crate) const PLURAL_LANGUAGES: &[&str] = &[")
for i in range(0, len(languages), 16):
print(" " + ", ".join(rust(l) for l in languages[i : i + 16]) + ",")
print("];")
print()
print("/// Percent signs around the number (CLDR's standard percent pattern and sign): locale, Latin")
print("/// digits or the locale's own, text before the number, text after it. A locale missing here")
print("/// takes its parent's by dropping trailing subtags, then `{n}%`.")
print("pub(crate) const PERCENT: &[(&str, bool, &str, &str)] = &[")
for (locale, latin), (before, after) in sorted(kept.items()):
print(f" ({rust(locale)}, {str(latin).lower()}, {rust(before)}, {rust(after)}),")
print("];")
main()
+144
View File
@@ -0,0 +1,144 @@
// @generated by generate.sh from Unicode CLDR 48.2.1 (Unicode License).
/// The languages CLDR has plural rules for; the baked data has them all.
pub(crate) const PLURAL_LANGUAGES: &[&str] = &[
"af", "ak", "am", "an", "ar", "ars", "as", "asa", "ast", "az", "bal", "be", "bem", "bez", "bg", "bho",
"blo", "bm", "bn", "bo", "br", "brx", "bs", "ca", "ce", "ceb", "cgg", "chr", "ckb", "cs", "csw", "cv",
"cy", "da", "de", "doi", "dsb", "dv", "dz", "ee", "el", "en", "eo", "es", "et", "eu", "fa", "ff",
"fi", "fil", "fo", "fr", "fur", "fy", "ga", "gd", "gl", "gsw", "gu", "guw", "gv", "ha", "haw", "he",
"hi", "hnj", "hr", "hsb", "hu", "hy", "ia", "id", "ie", "ig", "ii", "io", "is", "it", "iu", "ja",
"jbo", "jgo", "jmc", "jv", "jw", "ka", "kab", "kaj", "kcg", "kde", "kea", "kk", "kkj", "kl", "km", "kn",
"ko", "kok", "ks", "ksb", "ksh", "ku", "kw", "ky", "lag", "lb", "lg", "lij", "lkt", "lld", "ln", "lo",
"lt", "lv", "mas", "mg", "mgo", "mk", "ml", "mn", "mo", "mr", "ms", "mt", "my", "nah", "naq", "nb",
"nd", "ne", "nl", "nn", "nnh", "no", "nqo", "nr", "nso", "ny", "nyn", "om", "or", "os", "osa", "pa",
"pap", "pcm", "pl", "prg", "ps", "pt", "rm", "ro", "rof", "ru", "rwk", "sah", "saq", "sat", "sc", "scn",
"sd", "sdh", "se", "seh", "ses", "sg", "sgs", "sh", "shi", "si", "sk", "sl", "sma", "smi", "smj", "smn",
"sms", "sn", "so", "sq", "sr", "ss", "ssy", "st", "su", "sv", "sw", "syr", "ta", "te", "teo", "th",
"ti", "tig", "tk", "tl", "tn", "to", "tpi", "tr", "ts", "tzm", "ug", "uk", "und", "ur", "uz", "ve",
"vec", "vi", "vo", "vun", "wa", "wae", "wo", "xh", "xog", "yi", "yo", "yue", "zh", "zu",
];
/// Percent signs around the number (CLDR's standard percent pattern and sign): locale, Latin
/// digits or the locale's own, text before the number, text after it. A locale missing here
/// takes its parent's by dropping trailing subtags, then `{n}%`.
pub(crate) const PERCENT: &[(&str, bool, &str, &str)] = &[
("ar", true, "", "\u{200e}%\u{200e}"),
("ar-BH", false, "", "\u{66a}\u{61c}"),
("ar-DJ", false, "", "\u{66a}\u{61c}"),
("ar-EG", false, "", "\u{66a}\u{61c}"),
("ar-ER", false, "", "\u{66a}\u{61c}"),
("ar-IL", false, "", "\u{66a}\u{61c}"),
("ar-IQ", false, "", "\u{66a}\u{61c}"),
("ar-JO", false, "", "\u{66a}\u{61c}"),
("ar-KM", false, "", "\u{66a}\u{61c}"),
("ar-KW", false, "", "\u{66a}\u{61c}"),
("ar-LB", false, "", "\u{66a}\u{61c}"),
("ar-MR", false, "", "\u{66a}\u{61c}"),
("ar-OM", false, "", "\u{66a}\u{61c}"),
("ar-PS", false, "", "\u{66a}\u{61c}"),
("ar-QA", false, "", "\u{66a}\u{61c}"),
("ar-SA", false, "", "\u{66a}\u{61c}"),
("ar-SA", true, "", "\u{66a}"),
("ar-SD", false, "", "\u{66a}\u{61c}"),
("ar-SO", false, "", "\u{66a}\u{61c}"),
("ar-SO", true, "", "\u{66a}"),
("ar-SS", false, "", "\u{66a}\u{61c}"),
("ar-SY", false, "", "\u{66a}\u{61c}"),
("ar-TD", false, "", "\u{66a}\u{61c}"),
("ar-YE", false, "", "\u{66a}\u{61c}"),
("az-Arab", false, "", "\u{66a}"),
("bas", true, "", "\u{a0}%"),
("be", true, "", "\u{a0}%"),
("bgn", false, "", "\u{66a}"),
("bgn", true, "", "\u{66a}"),
("blo", true, "%\u{a0}", ""),
("br", true, "", "\u{a0}%"),
("bua", true, "", "\u{a0}%"),
("ca", true, "", "\u{a0}%"),
("ce", true, "", "\u{a0}%"),
("ckb", false, "", "\u{a0}\u{66a}"),
("cs", true, "", "\u{a0}%"),
("cv", true, "", "\u{a0}%"),
("da", true, "", "\u{a0}%"),
("de", true, "", "\u{a0}%"),
("de-CH", true, "", "%"),
("de-LI", true, "", "%"),
("dsb", true, "", "\u{a0}%"),
("dua", true, "", "\u{a0}%"),
("dz", false, "", "\u{a0}%"),
("dz", true, "", "\u{a0}%"),
("en-AT", true, "", "\u{a0}%"),
("en-DE", true, "", "\u{a0}%"),
("en-DK", true, "", "\u{a0}%"),
("en-FI", true, "", "\u{a0}%"),
("en-NO", true, "", "\u{a0}%"),
("en-SE", true, "", "\u{a0}%"),
("es", true, "", "\u{a0}%"),
("es-419", true, "", "%"),
("es-AR", true, "", "%"),
("es-BO", true, "", "%"),
("es-BR", true, "", "%"),
("es-BZ", true, "", "%"),
("es-CL", true, "", "%"),
("es-CO", true, "", "%"),
("es-CR", true, "", "%"),
("es-CU", true, "", "%"),
("es-DO", true, "", "%"),
("es-EC", true, "", "%"),
("es-GT", true, "", "%"),
("es-HN", true, "", "%"),
("es-MX", true, "", "%"),
("es-NI", true, "", "%"),
("es-PA", true, "", "%"),
("es-PE", true, "", "%"),
("es-PR", true, "", "%"),
("es-PY", true, "", "%"),
("es-SV", true, "", "%"),
("es-US", true, "", "%"),
("es-UY", true, "", "%"),
("es-VE", true, "", "%"),
("eu", true, "%\u{a0}", ""),
("fa", false, "", "\u{66a}"),
("fi", true, "", "\u{a0}%"),
("fo", true, "", "\u{a0}%"),
("fr", true, "", "\u{a0}%"),
("fr-CH", true, "", "%"),
("gl", true, "", "\u{a0}%"),
("gsw", true, "", "\u{a0}%"),
("hr", true, "", "\u{a0}%"),
("hsb", true, "", "\u{a0}%"),
("ht", true, "", "\u{a0}%"),
("kl", true, "", "\u{a0}%"),
("ks", false, "", "\u{66a}"),
("ksh", true, "", "\u{a0}%"),
("ku", true, "%", ""),
("ku-Arab", true, "", "%"),
("lb", true, "", "\u{a0}%"),
("lrc", false, "", "\u{66a}"),
("lt", true, "", "\u{a0}%"),
("mk", true, "", "\u{a0}%"),
("mzn", false, "", "\u{66a}"),
("nb", true, "", "\u{a0}%"),
("nds", true, "", "\u{a0}%"),
("nn", true, "", "\u{a0}%"),
("no", true, "", "\u{a0}%"),
("oc", true, "", "\u{202f}%"),
("pa-Arab", false, "", "\u{66a}"),
("ps", false, "", "\u{66a}"),
("qu", true, "", "\u{a0}%"),
("rn", true, "", "\u{a0}%"),
("ro", true, "", "\u{a0}%"),
("ru", true, "", "\u{a0}%"),
("sd", false, "", "\u{66a}\u{61c}"),
("sdh", false, "", "\u{66a}\u{61c}"),
("se", true, "", "\u{a0}%"),
("sk", true, "", "\u{a0}%"),
("sl", true, "", "\u{a0}%"),
("smn", true, "", "\u{a0}%"),
("sv", true, "", "\u{a0}%"),
("tk", true, "", "\u{a0}%"),
("tr", true, "%", ""),
("tt", true, "", "\u{a0}%"),
("uz-Arab", false, "", "\u{66a}"),
("zgh", true, "", "\u{a0}%"),
];
Binary file not shown.
+318
View File
@@ -0,0 +1,318 @@
//! Numbers, percentages and counts written for a formatting [`Region`], on ICU4X's decimal
//! formatter with CLDR data for every CLDR locale (`data/generate.sh`).
use std::fmt;
use fixed_decimal::{Decimal, Sign};
use icu_decimal::{DecimalFormatter, parts};
use icu_locale_core::Locale;
use icu_locale_core::extensions::unicode::{key, value};
use writeable::{Part, PartsWrite, Writeable};
use crate::Region;
use crate::data::{self, PERCENT};
/// Which digits numbers are written in.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
pub enum Digits {
/// The locale's own: Arabic-Indic in Egypt (`١٢٣`), Persian in Iran (`۱۲۳`), else `0`–`9`.
#[default]
Locale,
/// `0`–`9` everywhere, for users who read Arabic or Persian with Latin digits.
Latin,
}
/// How numbers are written in a formatting region: digits, decimal and grouping separators,
/// grouping sizes (`1,234,567`, Indian `12,34,567`) and the percent sign. Build it once per region
/// and keep it; [`crate::I18n`] holds one for counts, sizes and measures.
#[derive(Debug)]
pub struct Format {
/// `None` only if the data doesn't load: numbers are then written as Rust writes them.
decimal: Option<DecimalFormatter>,
/// The system's separators where they differ from the locale's; an empty `group` is none.
group: Option<String>,
point: Option<String>,
percent: (&'static str, &'static str),
}
impl Format {
/// The format for `region`'s number locale. With Latin digits, a decimal or grouping separator
/// the region carries (Windows' and macOS's own settings) replaces the locale's.
pub fn new(region: &Region, digits: Digits) -> Format {
let mut locale = Locale::try_from_str(&region.numbers).unwrap_or(Locale::UNKNOWN);
if digits == Digits::Latin {
locale.extensions.unicode.keywords.set(key!("nu"), value!("latn"));
}
let decimal = data::provider().and_then(|p| DecimalFormatter::try_new_with_buffer_provider(p, (&locale).into(), Default::default()).ok());
let mut format = Format { decimal, group: None, point: None, percent: ("", "%") };
// The locale's own separators and digits, read from a number that shows them all.
let probe = format.parts(&Decimal::try_from_str("-1234567.5").unwrap_or_default());
let latin = probe.integer.starts_with(|c: char| c.is_ascii_digit());
if latin {
if let Some(point) = region.decimal.map(String::from).filter(|p| *p != probe.point) {
// `1,234.5` with a decimal comma becomes `1.234,5`, never `1,234,5`.
if probe.group == point && region.grouping.is_none() {
format.group = Some(probe.point.clone());
}
format.point = Some(point);
}
if let Some(g) = region.grouping.map(|g| g.map(String::from).unwrap_or_default()).filter(|g| *g != probe.group) {
format.group = Some(g);
}
}
format.percent = percent_affixes(&region.numbers, latin);
format
}
/// The format for a locale tag (`de-CH`, `ar-EG`), with the locale's own digits.
pub fn from_tag(tag: &str) -> Format {
Format::new(&Region::from_tag(tag), Digits::Locale)
}
/// A whole number: `1,234,567`, `1.234.567`, `١٬٢٣٤٬٥٦٧`.
pub fn integer(&self, n: impl Into<i128>) -> String {
self.write(&Decimal::from(n.into()))
}
/// `value` rounded to `decimals` digits after the decimal separator; infinities and NaN are
/// `∞`, `-∞` and `NaN`.
pub fn number(&self, value: f64, decimals: usize) -> String {
match to_decimal(value, decimals) {
Some(d) => self.write(&d),
None => non_finite(value).to_owned(),
}
}
/// `ratio` as a percentage with `decimals` digits after the decimal separator: `0.5` is
/// `50%`, `50 %` (French, German), `%50` (Turkish), `٥٠٪؜` (Egyptian Arabic).
pub fn percent(&self, ratio: f64, decimals: usize) -> String {
let Some(d) = to_decimal(ratio * 100.0, decimals) else {
return format!("{}{}{}", self.percent.0, non_finite(ratio), self.percent.1);
};
let p = self.parts(&d);
format!("{}{}{}{}", p.sign, self.percent.0, p.text, self.percent.1)
}
/// `d` as text, the sign in place.
pub(crate) fn write(&self, d: &Decimal) -> String {
let p = self.parts(d);
p.sign + &p.text
}
fn parts(&self, d: &Decimal) -> Written {
let mut sink = Sink { group: self.group.as_deref(), point: self.point.as_deref(), into: Target::Text, w: Written::default() };
let written = match &self.decimal {
Some(f) => f.format(d).write_to_parts(&mut sink),
None => fmt::Write::write_str(&mut sink, &d.to_string()),
};
if written.is_err() {
return Written { text: d.to_string(), ..Written::default() };
}
sink.w
}
}
/// A formatted number taken apart: its sign, the rest, and the separators and integer digits as
/// the locale wrote them.
#[derive(Default)]
struct Written {
sign: String,
text: String,
integer: String,
group: String,
point: String,
}
#[derive(Clone, Copy, PartialEq)]
enum Target {
Text,
Sign,
/// A separator the region replaces; the locale's goes to `group` or `point` only.
Replaced,
}
/// Writes the formatter's parts into [`Written`], swapping in the region's separators.
struct Sink<'a> {
group: Option<&'a str>,
point: Option<&'a str>,
into: Target,
w: Written,
}
impl fmt::Write for Sink<'_> {
fn write_str(&mut self, s: &str) -> fmt::Result {
match self.into {
Target::Text => self.w.text.push_str(s),
Target::Sign => self.w.sign.push_str(s),
Target::Replaced => {}
}
Ok(())
}
}
impl PartsWrite for Sink<'_> {
type SubPartsWrite = Self;
fn with_part(&mut self, part: Part, mut f: impl FnMut(&mut Self) -> fmt::Result) -> fmt::Result {
let outer = self.into;
let start = self.w.text.len();
let replacement = match part {
p if p == parts::GROUP => self.group,
p if p == parts::DECIMAL => self.point,
_ => None,
};
if let Some(r) = replacement {
self.w.text.push_str(r);
self.into = Target::Replaced;
} else if part == parts::MINUS_SIGN || part == parts::PLUS_SIGN {
self.into = Target::Sign;
}
let result = f(self);
self.into = outer;
if replacement.is_none() {
let written = self.w.text.get(start..).unwrap_or_default();
let slot = match part {
p if p == parts::GROUP => &mut self.w.group,
p if p == parts::DECIMAL => &mut self.w.point,
p if p == parts::INTEGER => &mut self.w.integer,
_ => return result,
};
if slot.is_empty() {
*slot = written.to_owned();
}
}
result
}
}
/// `value` rounded to `decimals` digits after the point; `None` for infinities and NaN. A value
/// that rounds to zero has no sign.
pub(crate) fn to_decimal(value: f64, decimals: usize) -> Option<Decimal> {
if !value.is_finite() {
return None;
}
let mut d = Decimal::try_from_str(&format!("{value:.decimals$}")).ok()?;
if d.absolute.is_zero() {
d.sign = Sign::None;
}
Some(d)
}
/// CLDR's root symbols for a value that isn't finite.
fn non_finite(value: f64) -> &'static str {
match value {
f64::INFINITY => "∞",
f64::NEG_INFINITY => "-∞",
_ => "NaN",
}
}
/// The percent affixes for a number locale (`ar-EG`), found by dropping trailing subtags.
fn percent_affixes(tag: &str, latin: bool) -> (&'static str, &'static str) {
let mut tag = tag;
loop {
if let Some((_, _, before, after)) = PERCENT.iter().find(|(l, lat, _, _)| *lat == latin && l.eq_ignore_ascii_case(tag)) {
return (before, after);
}
match tag.rsplit_once('-') {
Some((parent, _)) => tag = parent,
None => return ("", "%"),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn latin(tag: &str) -> Format {
Format::new(&Region::from_tag(tag), Digits::Latin)
}
#[test]
fn locale_digits_and_separators() {
for (tag, integer, number) in [
("en", "1,234,567", "1,234.5"),
("ja", "1,234,567", "1,234.5"),
("de", "1.234.567", "1.234,5"),
("fr", "1\u{202f}234\u{202f}567", "1\u{202f}234,5"),
("hi", "12,34,567", "1,234.5"),
("ar-EG", "١٬٢٣٤٬٥٦٧", "١٬٢٣٤٫٥"),
("fa", "۱٬۲۳۴٬۵۶۷", "۱٬۲۳۴٫۵"),
] {
let f = Format::from_tag(tag);
assert_eq!((f.integer(1_234_567), f.number(1234.5, 1)), (integer.into(), number.into()), "{tag}");
}
// CLDR writes Arabic without a region in Latin digits.
assert_eq!(Format::from_tag("ar").number(1234.5, 1), "1,234.5");
}
#[test]
fn forced_latin_digits() {
assert_eq!(latin("ar-EG").number(1234.5, 1), "1,234.5");
assert_eq!(latin("fa").integer(1_234_567), "1,234,567");
assert_eq!(latin("de").number(1234.5, 1), "1.234,5", "a Latin locale is unchanged");
}
#[test]
fn cldr_separators_for_every_region() {
for (tag, want) in [
("en-PL", "1.234.567,5"),
("en-DE", "1.234.567,5"),
("ar-MA", "1.234.567,5"),
("de-CH", "1'234'567.5"),
("nb-NO", "1\u{a0}234\u{a0}567,5"),
("sk-SK", "1\u{a0}234\u{a0}567,5"),
("bn-BD", "১২,৩৪,৫৬৭.৫"),
("he-IL", "1,234,567.5"),
("km", "1,234,567.5"),
("lo", "1.234.567,5"),
] {
assert_eq!(Format::from_tag(tag).number(1_234_567.5, 1), want, "{tag}");
}
}
#[test]
fn system_separators() {
// A decimal comma the system sets moves the grouping out of its way.
let mut region = Region::from_tag("en-US");
region.decimal = Some(',');
assert_eq!(Format::new(&region, Digits::Locale).number(1234.5, 1), "1.234,5");
let mut region = Region::from_tag("de-DE");
region.grouping = Some(Some('\u{a0}'));
assert_eq!(Format::new(&region, Digits::Locale).number(1234.5, 1), "1\u{a0}234,5");
region.decimal = Some('.');
assert_eq!(Format::new(&region, Digits::Locale).number(1234.5, 1), "1\u{a0}234.5");
region.grouping = Some(None);
assert_eq!(Format::new(&region, Digits::Locale).integer(1_234_567), "1234567", "no grouping");
// A system's separators don't apply to Arabic-Indic digits.
let mut region = Region::from_tag("ar-EG");
region.grouping = Some(Some(','));
assert_eq!(Format::new(&region, Digits::Locale).number(1234.5, 1), "١٬٢٣٤٫٥");
}
#[test]
fn percentages() {
for (tag, want) in
[("en", "50%"), ("de", "50\u{a0}%"), ("fr", "12,5\u{a0}%"), ("tr", "%50"), ("ar-EG", "٥٠٪\u{61c}"), ("fa", "۵۰٪"), ("hi", "50%")]
{
let decimals = if tag == "fr" { 1 } else { 0 };
let ratio = if tag == "fr" { 0.125 } else { 0.5 };
assert_eq!(Format::from_tag(tag).percent(ratio, decimals), want, "{tag}");
}
assert_eq!(Format::from_tag("tr").percent(-0.2, 0), "-%20", "the sign leads the pattern");
assert_eq!(latin("fa").percent(0.5, 0), "50%");
}
#[test]
fn signs_and_odd_values() {
let en = Format::from_tag("en");
assert_eq!(en.number(-1234.5, 0), "-1,234");
assert_eq!(en.number(-0.04, 1), "0.0", "no sign on zero");
assert_eq!(en.number(f64::INFINITY, 1), "∞");
assert_eq!(en.number(f64::NEG_INFINITY, 1), "-∞");
assert_eq!(en.number(f64::NAN, 1), "NaN");
assert_eq!(Format::from_tag("de").percent(f64::NAN, 0), "NaN\u{a0}%");
assert_eq!(en.integer(-5_i64), "-5");
}
}
+192 -33
View File
@@ -3,18 +3,51 @@
//! An app embeds the [craft-i18n](https://github.com/benandez/craft-i18n) catalogs at build time
//! as an [`Embedded`] static, builds one [`I18n`] from it, and looks English source text up in a
//! [`Lang`]. A missing translation is the English itself; nothing here fails.
//!
//! Counts ([`I18n::trn`]), file sizes and measures write their numbers as the formatting
//! [`Region`] does ([`I18n::format`]): grouped (`1.234`, `12,34,567`) and in the region's digits
//! (`١٬٢٣٤`). Text that goes into documents or file names should use [`I18n::trn_plain`], which
//! writes `0`–`9` without grouping.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
mod catalog;
mod format;
mod number;
mod region;
mod size;
/// The CLDR number and plural data for every CLDR locale, from `data/generate.sh`.
mod data {
use std::sync::OnceLock;
use icu_locale_fallback::LocaleFallbacker;
use icu_provider_adapters::fallback::LocaleFallbackProvider;
use icu_provider_blob::BlobDataProvider;
include!("../data/locales.rs");
/// The number and plural data, read once; `None` if the blob doesn't load.
pub(crate) fn provider() -> Option<&'static LocaleFallbackProvider<BlobDataProvider>> {
static PROVIDER: OnceLock<Option<LocaleFallbackProvider<BlobDataProvider>>> = OnceLock::new();
PROVIDER
.get_or_init(|| {
let blob = BlobDataProvider::try_new_from_static_blob(include_bytes!("../data/numbers.postcard")).ok()?;
Some(LocaleFallbackProvider::new(blob, LocaleFallbacker::new().static_to_owned()))
})
.as_ref()
}
}
use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::sync::{Mutex, OnceLock, PoisonError};
use std::sync::{Arc, Mutex, OnceLock, PoisonError, RwLock};
use intl_pluralrules::{PluralCategory, PluralRuleType, PluralRules};
use unic_langid::LanguageIdentifier;
use icu_locale_core::Locale as IcuLocale;
use icu_plurals::{PluralCategory, PluralOperands, PluralRules};
pub use format::{Digits, Format};
pub use number::{NumberError, parse_number};
pub use region::{HourCycle, Measurement, Paper, Region};
pub use size::{FileSize, SizeUnit};
/// The catalogs an app embeds, one entry per locale: (locale, shared catalog, the app's catalog).
/// A missing catalog is `""`.
@@ -55,6 +88,8 @@ pub struct I18n {
pseudo: Mutex<BTreeMap<String, &'static str>>,
/// [`I18n::system_lang`], read once.
system: OnceLock<Lang>,
/// [`I18n::format`]; the system region's until [`I18n::set_format`].
format: RwLock<Option<Arc<Format>>>,
}
impl I18n {
@@ -105,7 +140,7 @@ impl I18n {
l.chain = chain;
}
}
(I18n { locales, pseudo: Mutex::default(), system: OnceLock::new() }, problems)
(I18n { locales, pseudo: Mutex::default(), system: OnceLock::new(), format: RwLock::default() }, problems)
}
/// The language with this code (any case): `en`, an offered locale, or in debug builds
@@ -161,9 +196,34 @@ impl I18n {
self.find(lang, &[("@id", id), ("", label)]).unwrap_or(label)
}
/// A count's wording in `lang`, `{n}` filled in. `one` and `other` are the English forms;
/// they are also the key of the `@plural` row, and the fallback.
/// A count's wording in `lang`, `{n}` filled in as [`I18n::format`] writes numbers: grouped and
/// in the region's digits. `one` and `other` are the English forms; they are also the key of
/// the `@plural` row, and the fallback.
pub fn trn(&self, lang: Lang, n: u64, one: &str, other: &str) -> String {
let format = self.format();
self.plural(lang, Some(PluralOperands::from(n)), n == 1, &format.integer(n), one, other)
}
/// [`I18n::trn`] with `{n}` in `0`–`9` without grouping (`Copy 1234`), for text that goes into
/// documents, file names or anything read back later.
pub fn trn_plain(&self, lang: Lang, n: u64, one: &str, other: &str) -> String {
self.plural(lang, Some(PluralOperands::from(n)), n == 1, &n.to_string(), one, other)
}
/// [`I18n::trn`] for a count with `decimals` digits after the decimal separator (`1.5 files`).
/// The plural form follows the digits shown, so `1.0` takes English `other`; infinities and NaN
/// (`∞`, `NaN`) take `other` too.
pub fn trn_decimal(&self, lang: Lang, n: f64, decimals: usize, one: &str, other: &str) -> String {
let format = self.format();
let Some(d) = format::to_decimal(n, decimals) else {
return self.plural(lang, None, false, &format.number(n, decimals), one, other);
};
let english_one = d.to_string() == "1";
self.plural(lang, Some(PluralOperands::from(&d)), english_one, &format.write(&d), one, other)
}
/// `operands` `None` is the `other` category.
fn plural(&self, lang: Lang, operands: Option<PluralOperands>, english_one: bool, n: &str, one: &str, other: &str) -> String {
let key = format!("{one}|{other}");
let chain = self.locale(lang).map_or(&[][..], |l| l.chain.as_slice());
let text = chain
@@ -172,19 +232,45 @@ impl I18n {
let locale = self.locales.get(i)?;
let forms = locale.rows.get("@plural")?.get(&key)?;
let (rules, categories) = locale.plural.as_ref()?;
let category = rules.select(n).ok()?;
// A category whole numbers up to 1000 never reach (French `many`) takes the last form.
let index = categories.iter().position(|c| *c == category).unwrap_or(categories.len().saturating_sub(1));
let category = operands.map_or(PluralCategory::Other, |o| rules.category_for(o));
// A category whole numbers up to 1000 never reach has no form of its own. Russian
// and Ukrainian decimals (`other`) take the `few` form (`1,5 файла`); any other
// (French `many` for a million) takes the last.
let index = categories
.iter()
.position(|c| *c == category)
.or_else(|| categories.iter().position(|c| *c == PluralCategory::Few))
.unwrap_or(categories.len().saturating_sub(1));
forms.split('|').nth(index)
})
.unwrap_or(if n == 1 { one } else { other });
let text = fmt(text, &[("n", &n.to_string())]);
.unwrap_or(if english_one { one } else { other });
let text = fmt(text, &[("n", n)]);
if lang == Lang::PSEUDO {
return marked(&text);
}
text
}
/// How counts, file sizes and measures write numbers: [`Region::system`]'s format with the
/// locale's digits until [`I18n::set_format`], and again after [`I18n::reset_format`].
pub fn format(&self) -> Arc<Format> {
if let Some(f) = self.format.read().unwrap_or_else(PoisonError::into_inner).as_ref() {
return Arc::clone(f);
}
let mut slot = self.format.write().unwrap_or_else(PoisonError::into_inner);
Arc::clone(slot.get_or_insert_with(|| Arc::new(Format::new(&Region::system(), Digits::Locale))))
}
/// Write numbers as `format` does from now on: the user's region setting, or Latin digits.
pub fn set_format(&self, format: Format) {
*self.format.write().unwrap_or_else(PoisonError::into_inner) = Some(Arc::new(format));
}
/// Write numbers as the system region does again, undoing [`I18n::set_format`].
pub fn reset_format(&self) {
*self.format.write().unwrap_or_else(PoisonError::into_inner) = None;
}
/// The languages to offer: English first, then the offered locales by code. Never the
/// pseudo locale.
pub fn languages(&self) -> Vec<Lang> {
@@ -367,17 +453,22 @@ fn bare(code: &str) -> Locale {
}
/// `code`'s CLDR cardinal rules and the categories whole numbers 0..=1000 reach, in CLDR order
/// (zero, one, two, few, many, other): one `@plural` form each. Regional and scripted codes fall
/// back to the language's rules. CLDR's `pt` is Brazilian (0 is singular), so Portuguese other
/// than `pt-br` takes `pt-PT`'s.
/// (zero, one, two, few, many, other): one `@plural` form each. The rules are looked up by
/// language alone: CLDR's parent of `sr-Latn` and `zh-Hant` is the root, whose only category is
/// `other`. CLDR's `pt` is Brazilian (0 is singular), so Portuguese other than `pt-br` takes
/// `pt-PT`'s. `None` for a language CLDR has no plural rules for.
fn plural_rules(code: &str) -> Option<(PluralRules, Vec<PluralCategory>)> {
let code = if code == "pt" || (code.starts_with("pt-") && code != "pt-br") { "pt-pt" } else { code };
let id: LanguageIdentifier = code.parse().ok()?;
let language = LanguageIdentifier::from_parts(id.language, None, None, &[]);
let rules = PluralRules::create(id, PluralRuleType::CARDINAL).or_else(|_| PluralRules::create(language, PluralRuleType::CARDINAL)).ok()?;
let id = IcuLocale::try_from_str(code).ok()?.id;
let language = id.language.as_str();
if !data::PLURAL_LANGUAGES.contains(&language) {
return None;
}
let brazil = id.region.is_some_and(|r| r.as_str() == "BR");
let locale = IcuLocale::try_from_str(if language == "pt" && !brazil { "pt-PT" } else { language }).ok()?;
let rules = PluralRules::try_new_cardinal_with_buffer_provider(data::provider()?, (&locale).into()).ok()?;
let mut categories = Vec::new();
for n in 0..=1000_u32 {
let c = rules.select(n).ok()?;
let c = rules.category_for(n);
if !categories.contains(&c) {
categories.push(c);
}
@@ -388,12 +479,12 @@ fn plural_rules(code: &str) -> Option<(PluralRules, Vec<PluralCategory>)> {
fn rank(c: &PluralCategory) -> u8 {
match c {
PluralCategory::ZERO => 0,
PluralCategory::ONE => 1,
PluralCategory::TWO => 2,
PluralCategory::FEW => 3,
PluralCategory::MANY => 4,
PluralCategory::OTHER => 5,
PluralCategory::Zero => 0,
PluralCategory::One => 1,
PluralCategory::Two => 2,
PluralCategory::Few => 3,
PluralCategory::Many => 4,
PluralCategory::Other => 5,
}
}
@@ -408,10 +499,12 @@ mod tests {
("de", "\tBad\n@nope\tA\tB\n\tUndo\tRückgängig\n", ""),
];
/// `embedded`, offering every locale in it.
/// `embedded`, offering every locale in it, with `en-US` numbers rather than the machine's.
fn all(embedded: &'static Embedded) -> (I18n, Vec<String>) {
let codes: Vec<&str> = embedded.iter().map(|(c, _, _)| *c).collect();
I18n::new(embedded, &codes)
let (i, problems) = I18n::new(embedded, &codes);
i.set_format(Format::from_tag("en-US"));
(i, problems)
}
fn i18n() -> I18n {
@@ -484,6 +577,7 @@ mod tests {
("fr", "@plural\t{n} page|{n} pages\t{n} page|{n} pages\n", ""),
("ru", "@plural\t{n} file|{n} files\t{n} файл|{n} файла|{n} файлов\n", ""),
("ja", "@plural\t{n} file|{n} files\t{n} 個\n", ""),
("ar", "@plural\t{n} file|{n} files\t{n} ملفات|{n} ملف واحد|{n} ملفان|{n} ملفات|{n} ملفًا|{n} ملف\n", ""),
];
#[test]
@@ -526,7 +620,56 @@ mod tests {
#[test]
fn beyond_sweep() {
let i = all(P).0;
assert_eq!(i.trn(lang(&i, "fr"), 1_000_000, "{n} page", "{n} pages"), "1000000 pages");
i.set_format(Format::from_tag("fr"));
assert_eq!(i.trn(lang(&i, "fr"), 1_000_000, "{n} page", "{n} pages"), "1\u{202f}000\u{202f}000 pages");
}
#[test]
fn counts_follow_the_region() {
let i = all(P).0;
i.set_format(Format::from_tag("ar-EG"));
assert_eq!(i.trn(lang(&i, "ar"), 1234, "{n} file", "{n} files"), "١٬٢٣٤ ملفًا");
i.set_format(Format::new(&Region::from_tag("ar-EG"), Digits::Latin));
assert_eq!(i.trn(lang(&i, "ar"), 1234, "{n} file", "{n} files"), "1,234 ملفًا");
// An English interface with a German region.
i.set_format(Format::from_tag("de-DE"));
assert_eq!(i.trn(Lang::EN, 12_345, "{n} file", "{n} files"), "12.345 files");
}
#[test]
fn plain_counts_and_the_system_format() {
let i = all(P).0;
i.set_format(Format::from_tag("ar-EG"));
assert_eq!(i.trn_plain(lang(&i, "ar"), 1234, "{n} file", "{n} files"), "1234 ملفًا");
i.set_format(Format::from_tag("de-DE"));
assert_eq!(i.trn_plain(Lang::EN, 12_345, "copy {n}", "copy {n}"), "copy 12345");
i.reset_format();
assert_eq!(
i.trn(Lang::EN, 5, "{n} file", "{n} files"),
fmt("{n} files", &[("n", &Format::new(&Region::system(), Digits::Locale).integer(5))])
);
}
#[test]
fn decimal_counts() {
let i = all(P).0;
i.set_format(Format::from_tag("en"));
assert_eq!(i.trn_decimal(Lang::EN, 1.5, 1, "{n} file", "{n} files"), "1.5 files");
assert_eq!(i.trn_decimal(Lang::EN, 1.0, 1, "{n} file", "{n} files"), "1.0 files", "visible decimals are plural");
assert_eq!(i.trn_decimal(Lang::EN, 1.0, 0, "{n} file", "{n} files"), "1 file");
// Russian decimals are `other`, which integers never reach: the `few` form.
i.set_format(Format::from_tag("ru"));
let ru = lang(&i, "ru");
assert_eq!(i.trn_decimal(ru, 1.5, 1, "{n} file", "{n} files"), "1,5 файла");
assert_eq!(i.trn_decimal(ru, 21.0, 0, "{n} file", "{n} files"), "21 файл");
// Arabic decimals are `other`.
i.set_format(Format::from_tag("ar-EG"));
let ar = lang(&i, "ar");
assert_eq!(i.trn_decimal(ar, 2.5, 1, "{n} file", "{n} files"), "٢٫٥ ملف");
assert_eq!(i.trn_decimal(ar, 3.0, 0, "{n} file", "{n} files"), "٣ ملفات");
// Not a number: the translated `other` form.
assert_eq!(i.trn_decimal(ru, f64::NAN, 1, "{n} file", "{n} files"), "NaN файла");
assert_eq!(i.trn_decimal(Lang::EN, f64::INFINITY, 1, "{n} file", "{n} files"), "∞ files");
}
static L: &Embedded =
@@ -677,15 +820,31 @@ mod tests {
assert_eq!(i.trn(lang(&i, "pt-br"), 0, "{n} file", "{n} files"), "0 arquivo");
}
/// The same counts as craft-i18n's checker (`plural_form_counts`), which rejects a catalog
/// whose `@plural` rows list any other.
/// The counts craft-i18n's checker expects, which rejects a catalog whose `@plural` rows list
/// any other: every directory in craft-i18n's `locales/`, then languages it may add.
#[test]
fn plural_form_counts() {
for (locale, n) in
[("en", 2), ("fr", 2), ("ja", 1), ("vi", 1), ("ru", 3), ("pl", 3), ("cs", 3), ("sl", 4), ("ar", 6), ("pt", 2), ("pt-br", 2), ("tr", 2)]
{
#[rustfmt::skip]
let catalogs = [
("ar", 6), ("bg", 2), ("cs", 3), ("da", 2), ("de", 2), ("el", 2), ("es", 2), ("et", 2),
("fi", 2), ("fr", 2), ("hr", 3), ("hu", 2), ("id", 1), ("it", 2), ("ja", 1), ("ka", 2),
("kk", 2), ("ko", 1), ("lt", 3), ("nl", 2), ("pl", 3), ("pt-br", 2), ("ru", 3), ("sl", 4),
("sr", 3), ("sr-latn", 3), ("sv", 2), ("te", 2), ("tr", 2), ("uk", 3), ("vi", 1),
("zh-hans", 1), ("zh-hant", 1),
];
let more = [("en", 2), ("pt", 2), ("he", 3), ("ro", 3), ("nb", 2), ("ca", 2), ("sk", 3), ("th", 1), ("ms", 1), ("fa", 2), ("hi", 2)];
for (locale, n) in catalogs.into_iter().chain(more) {
assert_eq!(plural_rules(locale).map(|(_, c)| c.len()), Some(n), "{locale}");
}
assert!(plural_rules("tlh").is_none(), "no CLDR rules");
}
#[test]
fn serbian_latin_plurals() {
let i = all(&[("sr-latn", "@plural\t{n} file|{n} files\t{n} fajl|{n} fajla|{n} fajlova\n", "")]).0;
let sr = lang(&i, "sr-latn");
let files = |n| i.trn(sr, n, "{n} file", "{n} files");
assert_eq!((files(1), files(3), files(5)), ("1 fajl".into(), "3 fajla".into(), "5 fajlova".into()));
}
#[test]
+453
View File
@@ -0,0 +1,453 @@
//! The formatting region: how numbers, times and measurements are written, apart from the
//! interface language. An English interface in Poland still writes `2,54 cm` on A4.
//!
//! The territory lists come from Unicode CLDR's supplemental `measurementData` (Unicode License):
//! `paperSize` US-Letter and `measurementSystem` US. The separators numbers are written with
//! follow the number locale's CLDR data ([`crate::Format`]); the region carries them only where
//! the system sets its own.
use std::sync::OnceLock;
/// Territories whose default paper is US Letter (CLDR `paperSize type="US-Letter"`).
const LETTER: &[&str] = &["BZ", "CA", "CL", "CO", "CR", "GT", "MX", "NI", "PA", "PH", "PR", "SV", "US", "VE"];
/// Territories that measure in inches (CLDR `measurementSystem type="US"`).
const INCHES: &[&str] = &["LR", "MM", "US"];
/// How lengths, distances and weights are measured.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum Measurement {
/// Centimetres, kilometres and kilograms (CLDR `metric` and `UK`).
Metric,
/// Inches, miles and pounds (CLDR `US`).
Us,
}
/// The paper a new document starts on.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum Paper {
A4,
Letter,
}
/// A clock the user chose, where the system says so apart from the locale.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum HourCycle {
/// 1 to 12 with AM and PM.
H12,
/// 0 to 23.
H23,
}
/// The conventions numbers, times and measurements follow. Read it with [`Region::system`] or,
/// for the user's saved choice, [`Region::from_setting`]; the fields are public so an app can
/// keep a separate choice of, say, measurement.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Region {
/// The locale numbers are written in, BCP 47 (`pl-PL`, `sr-Latn-RS`).
pub numbers: String,
/// The locale dates and times are written in, BCP 47.
pub time: String,
pub measurement: Measurement,
pub paper: Paper,
/// The decimal separator the system sets (Windows, macOS); `None` follows the locale.
pub decimal: Option<char>,
/// The grouping separator the system sets (Windows, macOS): `Some(None)` for none at all
/// (`1234567`); `None` follows the locale.
pub grouping: Option<Option<char>>,
/// The clock the system sets (Windows, macOS); `None` follows the locale.
pub hour_cycle: Option<HourCycle>,
}
impl Default for Region {
/// `en-US`: Letter, inches.
fn default() -> Self {
Region::from_parts(&Tag::default())
}
}
impl Region {
/// The region a BCP 47 or POSIX locale tag names (`pl-PL`, `de_AT.UTF-8@euro`, `zh-Hans-CN`,
/// `en-US-u-ms-metric`, Apple's `en_US@rg=plzzzz`). The Unicode extensions `ms` (measurement),
/// `hc` (clock) and `rg` (region override) are read. An empty or unreadable tag, `C` and
/// `POSIX` are `en-US`; a bare `en` is the United States, any other bare language has no
/// territory.
pub fn from_tag(tag: &str) -> Region {
Region::from_parts(&Tag::parse(tag).unwrap_or_default())
}
/// The region for an app's saved region setting: `""` or `auto` (any case) is
/// [`Region::system`], anything else a locale tag for [`Region::from_tag`].
pub fn from_setting(setting: &str) -> Region {
let setting = setting.trim();
if setting.is_empty() || setting.eq_ignore_ascii_case("auto") { Region::system() } else { Region::from_tag(setting) }
}
/// The system's region, read once, then cached:
///
/// - Windows: the regional format and its customised separators, measurement system, paper
/// and short time format (Settings › Time & language › Language & region).
/// - macOS and iOS: the current locale (its identifier carries the Region setting), its
/// separators and metric setting, and its hour format.
/// - Linux and the BSDs: `LC_ALL`, else each of `LC_NUMERIC`, `LC_TIME`, `LC_MEASUREMENT` and
/// `LC_PAPER` on its own, else `LANG`.
/// - The web and Android: the preferred language, the only locale they expose.
pub fn system() -> Region {
static SYSTEM: OnceLock<Region> = OnceLock::new();
SYSTEM.get_or_init(read_system).clone()
}
fn from_parts(tag: &Tag) -> Region {
let territory = tag.territory.as_str();
let measurement = tag.measurement.unwrap_or(if INCHES.contains(&territory) { Measurement::Us } else { Measurement::Metric });
Region {
numbers: tag.bcp47(),
time: tag.bcp47(),
measurement,
paper: if LETTER.contains(&territory) { Paper::Letter } else { Paper::A4 },
decimal: None,
grouping: None,
hour_cycle: tag.hour_cycle,
}
}
}
/// The parts of a locale tag the region uses.
#[derive(Clone, Debug, PartialEq, Eq)]
struct Tag {
/// Lower case (`pl`).
language: String,
/// Title case (`Latn`); empty when the tag has none.
script: String,
/// Upper case (`PL`, or a UN M.49 number like `419`); empty when the tag has none.
territory: String,
measurement: Option<Measurement>,
hour_cycle: Option<HourCycle>,
}
impl Default for Tag {
fn default() -> Self {
Tag { language: "en".into(), script: String::new(), territory: "US".into(), measurement: None, hour_cycle: None }
}
}
impl Tag {
fn parse(tag: &str) -> Option<Tag> {
// Locale tags are short; anything long is junk and only its start is read.
let tag: String = tag.chars().take(64).collect();
let (tag, modifier) = tag.split_once('@').unwrap_or((&tag, ""));
let tag = tag.split('.').next().unwrap_or_default().trim();
if tag.eq_ignore_ascii_case("c") || tag.eq_ignore_ascii_case("posix") {
return None;
}
let mut parts = tag.split(['-', '_']);
let language = parts.next().unwrap_or_default();
if !(2..=3).contains(&language.len()) || !language.chars().all(|c| c.is_ascii_alphabetic()) {
return None;
}
let mut out =
Tag { language: language.to_ascii_lowercase(), script: String::new(), territory: String::new(), measurement: None, hour_cycle: None };
let mut extension = None;
for p in parts.by_ref() {
// A singleton starts the extensions (`-u-…`, `-x-…`).
if p.len() == 1 {
extension = Some(p.to_ascii_lowercase());
break;
}
if out.territory.is_empty() && out.script.is_empty() && p.len() == 4 && p.chars().all(|c| c.is_ascii_alphabetic()) {
out.script = title_case(p);
} else if out.territory.is_empty() && is_territory(p) {
out.territory = p.to_ascii_uppercase();
}
}
let mut keywords: Vec<(String, String)> = Vec::new();
if extension.as_deref() == Some("u") {
let mut key = String::new();
for p in parts {
if p.len() == 1 {
break;
}
if p.len() == 2 {
key = p.to_ascii_lowercase();
} else if !key.is_empty() {
keywords.push((std::mem::take(&mut key), p.to_ascii_lowercase()));
}
}
}
// Apple's and ICU's `@rg=plzzzz;ms=metric` keywords, and glibc's `@latin` script modifier.
for m in modifier.split(';') {
match m.split_once('=') {
Some((k, v)) => keywords.push((k.trim().to_ascii_lowercase(), v.trim().to_ascii_lowercase())),
None if m.eq_ignore_ascii_case("latin") => out.script = "Latn".into(),
None if m.eq_ignore_ascii_case("cyrillic") => out.script = "Cyrl".into(),
None => {}
}
}
for (key, value) in keywords {
match (key.as_str(), value.as_str()) {
("ms", "metric" | "uksystem") => out.measurement = Some(Measurement::Metric),
("ms", "ussystem") => out.measurement = Some(Measurement::Us),
("hc", "h11" | "h12") => out.hour_cycle = Some(HourCycle::H12),
("hc", "h23" | "h24") => out.hour_cycle = Some(HourCycle::H23),
("rg", v) if v.len() == 6 && v.ends_with("zzzz") && v.get(..2).is_some_and(is_territory) => {
out.territory = v.get(..2).unwrap_or_default().to_ascii_uppercase();
}
_ => {}
}
}
if out.territory.is_empty() && out.language == "en" {
out.territory = "US".into();
}
Some(out)
}
fn bcp47(&self) -> String {
[self.language.as_str(), &self.script, &self.territory].into_iter().filter(|p| !p.is_empty()).collect::<Vec<_>>().join("-")
}
}
fn is_territory(p: &str) -> bool {
(p.len() == 2 && p.chars().all(|c| c.is_ascii_alphabetic())) || (p.len() == 3 && p.chars().all(|c| c.is_ascii_digit()))
}
fn title_case(p: &str) -> String {
let lower = p.to_ascii_lowercase();
let mut chars = lower.chars();
chars.next().map(|c| c.to_ascii_uppercase().to_string() + chars.as_str()).unwrap_or_default()
}
/// Whether a locale variable names the C locale (`C`, `C.UTF-8`, `POSIX`).
#[cfg(any(test, all(unix, not(any(target_vendor = "apple", target_os = "android")))))]
fn is_c_locale(v: &str) -> bool {
let v = v.trim();
v.eq_ignore_ascii_case("c") || v.eq_ignore_ascii_case("posix") || v.starts_with("C.")
}
/// The region from the POSIX locale variables `var` reads. An empty variable is unset; `C` in
/// `LC_ALL` or an `LC_*` variable is the C locale, written as `en-US`. `LANG` set to `C` or unset
/// gives way to the platform's preferred locale.
#[cfg(any(test, all(unix, not(any(target_vendor = "apple", target_os = "android")))))]
fn from_posix(var: impl Fn(&str) -> Option<String>, platform: impl FnOnce() -> Option<String>) -> Region {
let var = |name: &str| var(name).filter(|v| !v.trim().is_empty());
if let Some(all) = var("LC_ALL") {
return Region::from_tag(&all);
}
let lang = var("LANG").filter(|v| !is_c_locale(v)).or_else(platform).unwrap_or_default();
let tag = |name: &str| var(name).unwrap_or_else(|| lang.clone());
let time = Region::from_tag(&tag("LC_TIME"));
Region {
time: time.time,
hour_cycle: time.hour_cycle,
measurement: Region::from_tag(&tag("LC_MEASUREMENT")).measurement,
paper: Region::from_tag(&tag("LC_PAPER")).paper,
..Region::from_tag(&tag("LC_NUMERIC"))
}
}
/// The region from the values under `HKEY_CURRENT_USER\Control Panel\International` that `value`
/// reads, where Windows keeps the regional format and the user's changes to it. `LocaleName` is
/// the format (`pl-PL`), else the platform's preferred locale.
#[cfg(any(test, windows))]
fn from_windows(value: impl Fn(&str) -> Option<String>, platform: impl FnOnce() -> Option<String>) -> Region {
let mut region = Region::from_tag(&value("LocaleName").or_else(platform).unwrap_or_default());
region.decimal = value("sDecimal").as_deref().and_then(single_char);
region.grouping = value("sThousand").as_deref().and_then(grouping);
match value("iMeasure").as_deref().map(str::trim) {
Some("0") => region.measurement = Measurement::Metric,
Some("1") => region.measurement = Measurement::Us,
_ => {}
}
// DMPAPER_LETTER and DMPAPER_A4; other sizes keep the territory's.
match value("iPaperSize").as_deref().map(str::trim) {
Some("1") => region.paper = Paper::Letter,
Some("9") => region.paper = Paper::A4,
_ => {}
}
region.hour_cycle = value("sShortTime").as_deref().and_then(hour_cycle_of).or(region.hour_cycle);
region
}
/// The region from Apple's current locale: its identifier (`en_PL`, `en_GB@rg=uszzzz`), its
/// metric setting and separators, and the hour format its `j` skeleton gives (`HH`, `h a`).
#[cfg(any(test, target_vendor = "apple"))]
fn from_apple(identifier: &str, metric: bool, decimal: &str, grouping: &str, hours: Option<&str>) -> Region {
let mut region = Region::from_tag(identifier);
region.measurement = if metric { Measurement::Metric } else { Measurement::Us };
region.decimal = single_char(decimal);
region.grouping = self::grouping(grouping);
region.hour_cycle = hours.and_then(hour_cycle_of).or(region.hour_cycle);
region
}
/// A grouping separator the system sets: none at all when empty (Windows' `sThousand` the user
/// cleared), else one character.
#[cfg(any(test, windows, target_vendor = "apple"))]
fn grouping(s: &str) -> Option<Option<char>> {
if s.is_empty() { Some(None) } else { single_char(s).map(Some) }
}
/// A separator that is one character; Windows allows up to three, which the region can't carry.
#[cfg(any(test, windows, target_vendor = "apple"))]
fn single_char(s: &str) -> Option<char> {
let mut chars = s.chars();
let c = chars.next()?;
chars.next().is_none().then_some(c)
}
/// The clock a time pattern (`HH:mm`, `h:mm tt`, `h 'Uhr'`) uses; quoted literals are skipped.
#[cfg(any(test, windows, target_vendor = "apple"))]
fn hour_cycle_of(pattern: &str) -> Option<HourCycle> {
let mut quoted = false;
for c in pattern.chars() {
match c {
'\'' => quoted = !quoted,
'H' | 'k' if !quoted => return Some(HourCycle::H23),
'h' | 'K' if !quoted => return Some(HourCycle::H12),
_ => {}
}
}
None
}
#[cfg(windows)]
fn read_system() -> Region {
let key = windows_registry::CURRENT_USER.open(r"Control Panel\International").ok();
from_windows(|name| key.as_ref()?.get_string(name).ok(), sys_locale::get_locale)
}
#[cfg(target_vendor = "apple")]
fn read_system() -> Region {
use objc2_foundation::{NSDateFormatter, NSLocale, NSString};
let locale = NSLocale::currentLocale();
let hours = NSDateFormatter::dateFormatFromTemplate_options_locale(&NSString::from_str("j"), 0, Some(&locale)).map(|f| f.to_string());
from_apple(
&locale.localeIdentifier().to_string(),
locale.usesMetricSystem(),
&locale.decimalSeparator().to_string(),
&locale.groupingSeparator().to_string(),
hours.as_deref(),
)
}
#[cfg(all(unix, not(any(target_vendor = "apple", target_os = "android"))))]
fn read_system() -> Region {
from_posix(|name| std::env::var(name).ok(), sys_locale::get_locale)
}
#[cfg(not(any(windows, unix)))]
fn read_system() -> Region {
Region::from_tag(&sys_locale::get_locale().unwrap_or_default())
}
#[cfg(target_os = "android")]
fn read_system() -> Region {
Region::from_tag(&sys_locale::get_locale().unwrap_or_default())
}
#[cfg(test)]
mod tests {
use super::*;
fn conventions(r: &Region) -> (Paper, Measurement) {
(r.paper, r.measurement)
}
#[test]
fn reads_locale_tags() {
assert_eq!(Region::from_tag("pl-PL").numbers, "pl-PL");
assert_eq!(Region::from_tag("de_AT.UTF-8@euro").numbers, "de-AT");
assert_eq!(Region::from_tag("zh-hans-cn").numbers, "zh-Hans-CN");
assert_eq!(Region::from_tag("sr_RS@latin").numbers, "sr-Latn-RS");
assert_eq!(Region::from_tag("es-419").numbers, "es-419");
assert_eq!(Region::from_tag("fr").numbers, "fr");
assert_eq!(Region::from_tag("en").numbers, "en-US", "bare English is the US");
for junk in ["", "C", "POSIX", "C.UTF-8", "1234", "x", "--", "ünïcode-ÄÄ", &"a".repeat(10_000)] {
assert_eq!(Region::from_tag(junk), Region::default(), "{junk:?}");
}
}
#[test]
fn unicode_extensions_and_apple_keywords() {
let r = Region::from_tag("en-US-u-ms-metric-hc-h23");
assert_eq!((r.numbers.as_str(), r.measurement, r.hour_cycle), ("en-US", Measurement::Metric, Some(HourCycle::H23)));
assert_eq!(Region::from_tag("en-GB-u-hc-h12").hour_cycle, Some(HourCycle::H12));
assert_eq!(Region::from_tag("de-DE-x-ms-ussystem").measurement, Measurement::Metric, "private use isn't read");
let r = Region::from_tag("en_GB@rg=uszzzz");
assert_eq!((r.numbers.as_str(), r.paper, r.measurement), ("en-US", Paper::Letter, Measurement::Us));
assert_eq!(Region::from_tag("en_US@rg=plzzzz;ms=metric").numbers, "en-PL");
}
/// Paper and measurement follow the territory; separators are left to the number locale.
#[test]
fn conventions_follow_the_territory() {
assert_eq!(conventions(&Region::from_tag("pl-PL")), (Paper::A4, Measurement::Metric));
assert_eq!(conventions(&Region::from_tag("en-US")), (Paper::Letter, Measurement::Us));
assert_eq!(conventions(&Region::from_tag("fr-CA")), (Paper::Letter, Measurement::Metric));
assert_eq!(conventions(&Region::from_tag("en-GB")), (Paper::A4, Measurement::Metric));
let r = Region::from_tag("de-CH");
assert_eq!((r.decimal, r.grouping), (None, None));
assert_eq!(Region::from_tag("es-MX").paper, Paper::Letter);
assert_eq!(Region::from_tag("ja-JP").paper, Paper::A4);
}
#[test]
fn a_saved_setting_overrides_the_system() {
assert_eq!(Region::from_setting(" pl-PL "), Region::from_tag("pl-PL"));
assert_eq!(Region::from_setting("AUTO"), Region::system());
assert_eq!(Region::from_setting(""), Region::system());
}
#[test]
fn posix_variables_set_each_convention() {
let env = |pairs: &'static [(&'static str, &'static str)]| move |n: &str| pairs.iter().find(|(k, _)| *k == n).map(|(_, v)| v.to_string());
let none = || None;
let r = from_posix(env(&[("LANG", "en_US.UTF-8"), ("LC_NUMERIC", "pl_PL.UTF-8"), ("LC_TIME", "en_GB.UTF-8"), ("LC_PAPER", "de_DE")]), none);
assert_eq!((r.numbers.as_str(), r.time.as_str(), r.decimal), ("pl-PL", "en-GB", None));
assert_eq!((r.paper, r.measurement), (Paper::A4, Measurement::Us), "paper from LC_PAPER, measurement from LANG");
assert_eq!(from_posix(env(&[("LANG", "it_IT.UTF-8"), ("LC_ALL", "en_US"), ("LC_PAPER", "de_DE")]), none).paper, Paper::Letter);
assert_eq!(from_posix(env(&[("LANG", "en_US"), ("LC_MEASUREMENT", "it_IT")]), none).measurement, Measurement::Metric);
assert_eq!(from_posix(env(&[("LANG", "C.UTF-8"), ("LC_ALL", "")]), || Some("it-IT".into())).numbers, "it-IT");
assert_eq!(from_posix(env(&[]), none), Region::default());
// `C` in an LC_* variable is the C locale's conventions, not an unset variable.
let r = from_posix(env(&[("LANG", "de_DE.UTF-8"), ("LC_NUMERIC", "C"), ("LC_TIME", "C.UTF-8")]), none);
assert_eq!((r.numbers.as_str(), r.time.as_str(), r.paper), ("en-US", "en-US", Paper::A4));
assert_eq!(from_posix(env(&[("LANG", "de_DE.UTF-8"), ("LC_ALL", "C")]), none), Region::default());
assert_eq!(from_posix(env(&[("LANG", "C"), ("LC_NUMERIC", "")]), || Some("it-IT".into())).numbers, "it-IT");
}
#[test]
fn windows_regional_format_and_its_changes() {
let reg = |pairs: &'static [(&'static str, &'static str)]| move |n: &str| pairs.iter().find(|(k, _)| *k == n).map(|(_, v)| v.to_string());
let r = from_windows(
reg(&[("LocaleName", "pl-PL"), ("sDecimal", ","), ("sThousand", "\u{a0}"), ("iMeasure", "0"), ("sShortTime", "HH:mm")]),
|| None,
);
assert_eq!((r.numbers.as_str(), r.decimal, r.grouping, r.hour_cycle), ("pl-PL", Some(','), Some(Some('\u{a0}')), Some(HourCycle::H23)));
// An English format in the US with the user's metric and A4 choices and a decimal comma.
let r = from_windows(
reg(&[("LocaleName", "en-US"), ("sDecimal", ","), ("iMeasure", "0"), ("iPaperSize", "9"), ("sShortTime", "h:mm tt")]),
|| None,
);
assert_eq!(
(r.decimal, r.grouping, r.measurement, r.paper, r.hour_cycle),
(Some(','), None, Measurement::Metric, Paper::A4, Some(HourCycle::H12))
);
let r = from_windows(reg(&[("sDecimal", "dec"), ("sThousand", "")]), || Some("de-DE".into()));
assert_eq!((r.numbers.as_str(), r.decimal), ("de-DE", None), "a separator longer than one character keeps the locale's");
assert_eq!(r.grouping, Some(None), "an empty grouping separator is no grouping");
assert_eq!(from_windows(reg(&[("sThousand", "abc")]), || None).grouping, None);
}
#[test]
fn apple_locale_values() {
let r = from_apple("en_PL", true, ",", "\u{a0}", Some("HH"));
assert_eq!(
(r.numbers.as_str(), r.paper, r.decimal, r.grouping, r.hour_cycle),
("en-PL", Paper::A4, Some(','), Some(Some('\u{a0}')), Some(HourCycle::H23))
);
let r = from_apple("en_US", false, ".", ",", Some("h a"));
assert_eq!((r.measurement, r.hour_cycle), (Measurement::Us, Some(HourCycle::H12)));
assert_eq!(from_apple("de_DE", true, ",", ".", Some("H 'Uhr'")).hour_cycle, Some(HourCycle::H23));
assert_eq!(hour_cycle_of("'h' mm"), None, "quoted letters are text");
}
}
+256
View File
@@ -0,0 +1,256 @@
//! File sizes and other measures with a unit, in the interface language.
//!
//! Unit names are catalog rows in context `unit`, keyed by the English template with the
//! number as `{n}` (`{n} MB`, `{n} px`, `{n}%`), so a translation can change the symbol and its
//! spacing (`{n} Mo`, `{n} %`). They are looked up with [`I18n::get`], never the plain row: a
//! plain `B` row means bold. A byte count in words is the `@plural` row `{n} byte|{n} bytes`.
use crate::{I18n, Lang, fmt};
/// The context unit rows are in.
const CONTEXT: &str = "unit";
/// A file-size unit.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum SizeUnit {
Byte,
Kilo,
Mega,
Giga,
Tera,
}
impl SizeUnit {
const ALL: [SizeUnit; 5] = [SizeUnit::Byte, SizeUnit::Kilo, SizeUnit::Mega, SizeUnit::Giga, SizeUnit::Tera];
}
/// How [`I18n::file_size`] writes a byte count: the step between units, their names, and the
/// digits after the decimal point. Start from [`FileSize::BINARY`], [`FileSize::IEC`] or
/// [`FileSize::DECIMAL`] and adjust:
///
/// ```
/// use craft_i18n::{FileSize, SizeUnit};
/// // `12 MB`, `3.4 MB`, `640 KB`: decimal steps, whole kilobytes, no decimals from 10 MB.
/// let style = FileSize::DECIMAL.smallest(SizeUnit::Kilo).largest(SizeUnit::Mega).unit_decimals(SizeUnit::Kilo, 0).whole_from(10);
/// ```
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct FileSize {
step: u64,
iec: bool,
/// Per unit, in [`SizeUnit::ALL`] order; bytes are always whole.
decimals: [u8; 5],
whole_from: u32,
smallest: SizeUnit,
largest: SizeUnit,
byte_symbol: bool,
}
impl FileSize {
/// Steps of 1024 named KB, MB, GB and TB, as Windows Explorer shows sizes; one decimal, bytes
/// in words.
pub const BINARY: FileSize = FileSize {
step: 1024,
iec: false,
decimals: [0, 1, 1, 1, 1],
whole_from: 0,
smallest: SizeUnit::Byte,
largest: SizeUnit::Tera,
byte_symbol: false,
};
/// Steps of 1024 named KiB, MiB, GiB and TiB.
pub const IEC: FileSize = FileSize { iec: true, ..FileSize::BINARY };
/// Steps of 1000 named KB, MB, GB and TB, as macOS shows sizes and as download and disk sizes
/// are sold.
pub const DECIMAL: FileSize = FileSize { step: 1000, ..FileSize::BINARY };
/// `n` digits after the decimal point in every unit above bytes.
pub const fn decimals(mut self, n: u8) -> FileSize {
self.decimals = [0, n, n, n, n];
self
}
/// `n` digits after the decimal point in `unit` (`0` for whole kilobytes, `2` for gigabytes).
pub const fn unit_decimals(mut self, unit: SizeUnit, n: u8) -> FileSize {
if !matches!(unit, SizeUnit::Byte) {
self.decimals[unit as usize] = n;
}
self
}
/// No decimals from `n` of a unit up: `whole_from(10)` writes `9.5 MB` and `12 MB`. `0`, the
/// default, keeps them.
pub const fn whole_from(mut self, n: u32) -> FileSize {
self.whole_from = n;
self
}
/// The smallest unit shown. Sizes below it are written in it, rounded up, so a file that
/// isn't empty never reads `0 KB`.
pub const fn smallest(mut self, unit: SizeUnit) -> FileSize {
self.smallest = unit;
self
}
/// The largest unit shown; larger sizes are counted in it (`2048 GB`).
pub const fn largest(mut self, unit: SizeUnit) -> FileSize {
self.largest = unit;
self
}
/// Bytes as the symbol `B` (`512 B`) instead of words (`512 bytes`).
pub const fn byte_symbol(mut self) -> FileSize {
self.byte_symbol = true;
self
}
/// The unit `bytes` is written in, the number in it and its digits after the point.
fn parts(&self, bytes: u64) -> (SizeUnit, f64, usize) {
let (smallest, largest) = (self.smallest.min(self.largest), self.largest);
let mut unit = smallest;
for &u in SizeUnit::ALL.iter().filter(|u| (smallest..=largest).contains(u)) {
if bytes as f64 >= self.scale(u) {
unit = u;
}
}
loop {
let value = bytes as f64 / self.scale(unit);
let round = |decimals: u8| {
let factor = 10f64.powi(i32::from(decimals));
(if unit == smallest && bytes > 0 && value < 1.0 { (value * factor).ceil() } else { (value * factor).round() }) / factor
};
let mut decimals = if unit == SizeUnit::Byte { 0 } else { self.decimals.get(unit as usize).copied().unwrap_or(0) };
let mut rounded = round(decimals);
// After rounding, so 9.96 MB, which rounds to 10.0, is written 10 MB.
if self.whole_from > 0 && rounded >= f64::from(self.whole_from) {
decimals = 0;
rounded = round(0);
}
// 1023.96 KB rounds to 1024.0 KB: the next unit writes it as 1.0 MB.
if rounded >= self.step as f64
&& let Some(&next) = SizeUnit::ALL.get(unit as usize + 1)
&& next <= largest
{
unit = next;
continue;
}
return (unit, rounded, usize::from(decimals));
}
}
fn scale(&self, unit: SizeUnit) -> f64 {
(self.step as f64).powi(unit as i32)
}
/// The English template for `unit`, which is also its catalog key.
fn template(&self, unit: SizeUnit) -> &'static str {
match (unit, self.iec) {
(SizeUnit::Byte, _) => "{n} B",
(SizeUnit::Kilo, false) => "{n} KB",
(SizeUnit::Mega, false) => "{n} MB",
(SizeUnit::Giga, false) => "{n} GB",
(SizeUnit::Tera, false) => "{n} TB",
(SizeUnit::Kilo, true) => "{n} KiB",
(SizeUnit::Mega, true) => "{n} MiB",
(SizeUnit::Giga, true) => "{n} GiB",
(SizeUnit::Tera, true) => "{n} TiB",
}
}
}
impl I18n {
/// `bytes` as a file size in `lang`, written the way `style` says (`1.5 MB`, `512 bytes`), the
/// number as [`I18n::format`] writes it (`1,5 Mo`).
pub fn file_size(&self, lang: Lang, bytes: u64, style: FileSize) -> String {
let (unit, n, decimals) = style.parts(bytes);
if unit == SizeUnit::Byte && !style.byte_symbol {
return self.trn(lang, bytes, "{n} byte", "{n} bytes");
}
self.unit_text(lang, style.template(unit), &self.format().number(n, decimals))
}
/// `value` with `decimals` digits after the decimal separator in the unit template `unit`, an
/// English catalog key with the number as `{n}` (`{n} px`, `{n} dB`), the number as
/// [`I18n::format`] writes it. Percentages are [`crate::Format::percent`].
pub fn measure(&self, lang: Lang, value: f64, decimals: usize, unit: &str) -> String {
self.unit_text(lang, unit, &self.format().number(value, decimals))
}
fn unit_text(&self, lang: Lang, template: &str, n: &str) -> String {
fmt(self.get(lang, CONTEXT, template).unwrap_or(template), &[("n", n)])
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{Embedded, Format};
static E: &Embedded = &[(
"fr",
"unit\t{n} KB\t{n} Ko\nunit\t{n} MB\t{n} Mo\nunit\t{n} B\t{n} o\nunit\t{n}%\t{n} %\n\tB\tG\n@plural\t{n} byte|{n} bytes\t{n} octet|{n} octets\n",
"",
)];
fn en(bytes: u64, style: FileSize) -> String {
let (i18n, _) = I18n::new(&[], &[]);
i18n.set_format(Format::from_tag("en-US"));
i18n.file_size(Lang::EN, bytes, style)
}
#[test]
fn binary_decimal_and_iec_steps() {
assert_eq!(en(512, FileSize::BINARY), "512 bytes");
assert_eq!(en(1023, FileSize::BINARY), "1,023 bytes");
assert_eq!(en(1, FileSize::BINARY), "1 byte");
assert_eq!(en(1536, FileSize::BINARY), "1.5 KB");
assert_eq!(en(1536, FileSize::IEC), "1.5 KiB");
assert_eq!(en(1500, FileSize::DECIMAL), "1.5 KB");
assert_eq!(en(3 << 30, FileSize::BINARY), "3.0 GB");
assert_eq!(en(5 << 40, FileSize::IEC.decimals(0)), "5 TiB");
assert_eq!(en(512, FileSize::BINARY.byte_symbol()), "512 B");
}
#[test]
fn precision_and_unit_range() {
// Rounding up to the next unit's step moves to that unit.
assert_eq!(en(1_048_575, FileSize::BINARY), "1.0 MB");
assert_eq!(en(999_999, FileSize::DECIMAL.decimals(0)), "1 MB");
assert_eq!(en(1_610_612_736, FileSize::BINARY.unit_decimals(SizeUnit::Giga, 2)), "1.50 GB");
// Lightcraft's denoise sizes: whole kilobytes, at least 1, no decimals from 10 MB.
let style = FileSize::DECIMAL.smallest(SizeUnit::Kilo).largest(SizeUnit::Mega).unit_decimals(SizeUnit::Kilo, 0).whole_from(10);
assert_eq!(en(1, style), "1 KB");
assert_eq!(en(0, style), "0 KB");
assert_eq!(en(640_400, style), "640 KB");
assert_eq!(en(3_400_000, style), "3.4 MB");
assert_eq!(en(12_600_000, style), "13 MB");
assert_eq!(en(9_960_000, style), "10 MB", "whole once rounded to 10");
assert_eq!(en(9_940_000, style), "9.9 MB");
assert_eq!(en(5_000_000_000, style), "5,000 MB");
// Only sizes below the smallest unit round up.
assert_eq!(en(1025, FileSize::BINARY.smallest(SizeUnit::Kilo).unit_decimals(SizeUnit::Kilo, 0)), "1 KB");
assert_eq!(en(100, FileSize::BINARY.smallest(SizeUnit::Kilo).unit_decimals(SizeUnit::Kilo, 0)), "1 KB");
}
#[test]
fn unit_names_are_catalog_rows() {
let (i18n, problems) = I18n::new(E, &["fr"]);
assert!(problems.is_empty(), "{problems:?}");
let fr = i18n.from_code("fr").unwrap();
i18n.set_format(Format::from_tag("fr-FR"));
assert_eq!(i18n.file_size(fr, 1536, FileSize::BINARY), "1,5 Ko");
assert_eq!(i18n.file_size(fr, 512, FileSize::BINARY), "512 octets");
assert_eq!(i18n.file_size(fr, 512, FileSize::BINARY.byte_symbol()), "512 o", "not the plain row B (bold)");
assert_eq!(i18n.file_size(fr, 3 << 30, FileSize::BINARY), "3,0 GB", "a missing row is English");
assert_eq!(i18n.measure(fr, 50.0, 0, "{n}%"), "50 %");
assert_eq!(i18n.measure(Lang::EN, 12.34, 1, "{n} px"), "12,3 px", "numbers follow the region, not the language");
}
#[test]
fn locale_digits() {
let (i18n, _) = I18n::new(&[], &[]);
i18n.set_format(Format::from_tag("ar-EG"));
assert_eq!(i18n.file_size(Lang::EN, 1536, FileSize::BINARY), "١٫٥ KB");
i18n.set_format(Format::from_tag("hi"));
assert_eq!(i18n.file_size(Lang::EN, 1_234_567, FileSize::DECIMAL.largest(SizeUnit::Byte).byte_symbol()), "12,34,567 B");
}
}