Merge #206: craft-i18n: search folding and locale sorting

# Conflicts:
#	crates/i18n/Cargo.toml
#	crates/i18n/src/lib.rs
This commit is contained in:
aiongg committed 2026-10-24 02:25:27 +00:00
commit fa78cb52a5
10 files changed
+848 -3

No files matched your search

+2 -2
View File
@@ -25,5 +25,5 @@ jobs:
with:
components: rustfmt, clippy
- run: cargo fmt --check
- run: cargo clippy -p craft-i18n --all-targets --locked -- -D warnings
- run: cargo test -p craft-i18n --locked -- --nocapture
- run: cargo clippy -p craft-i18n --all-targets --features collate --locked -- -D warnings
- run: cargo test -p craft-i18n --features collate --locked -- --nocapture
+1
View File
@@ -9,6 +9,7 @@ author, source and license. Library code and original assets are under the repos
| `crates/cms/profiles/photocraft-coated-cmyk.icc` | PhotoCraft contributors; generated by `craft_cms::synth::coated_cmyk` | [PhotoCraft `crates/cms`](https://github.com/storytold/photocraft/tree/b3222ee8e7e6bcb4799c37f904c595f20f9baf5e/crates/cms) | CC0-1.0 |
| `crates/cms/profiles/craft-coated-fogra39.icc` | craft-libs contributors; generated by `crates/cms/tests/fogra39.rs` from the FOGRA39 data below | this repository | CC0-1.0 |
| `crates/cms/tests/data/FOGRA39.txt` (unmodified) | Fogra | [Fogra characterisation data](https://fogra.org/en/downloads/work-tools/characterisation-data), `FOGRA39_to_FOGRA60_v2.zip` | Fogra terms: free use and redistribution, unmodified, with Fogra identified as the source; "FOGRA39" identifies the reference data only and implies no certification, approval or endorsement by Fogra (full text in [NOTICE](NOTICE)) |
| `crates/i18n/data/collation.postcard` | Unicode, Inc. (ICU 78.1 collation data, CLDR 48.2.1); generated by `crates/i18n/data/generate-collation.sh` | [ICU](https://github.com/unicode-org/icu/releases/tag/release-78.1rc) | Unicode-3.0 |
Original assets are covered by the repository licence.
Generated
+133
View File
@@ -624,6 +624,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"
@@ -790,6 +799,14 @@ version = "0.1.0"
name = "craft-i18n"
version = "0.1.0"
dependencies = [
"icu_collator",
"icu_locale_core",
"icu_locale_fallback",
"icu_normalizer",
"icu_properties",
"icu_provider",
"icu_provider_adapters",
"icu_provider_blob",
"intl_pluralrules",
"log",
"objc2-foundation 0.3.2",
@@ -1254,6 +1271,18 @@ dependencies = [
"bytemuck",
]
[[package]]
name = "embedded-io"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced"
[[package]]
name = "embedded-io"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d"
[[package]]
name = "encoding_rs"
version = "0.8.42"
@@ -2066,6 +2095,24 @@ dependencies = [
"cc",
]
[[package]]
name = "icu_collator"
version = "2.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08984ed58ac439ebf3e13d2cf26b0c46a60afcd21721c1d14087c0b240344dda"
dependencies = [
"icu_collections",
"icu_locale_core",
"icu_normalizer",
"icu_properties",
"icu_provider",
"serde",
"smallvec",
"utf16_iter",
"utf8_iter",
"zerovec",
]
[[package]]
name = "icu_collections"
version = "2.3.0"
@@ -2074,6 +2121,7 @@ checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513"
dependencies = [
"displaydoc",
"potential_utf",
"serde",
"utf8_iter",
"yoke",
"zerofrom",
@@ -2088,11 +2136,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"
@@ -2103,7 +2172,11 @@ dependencies = [
"icu_normalizer_data",
"icu_properties",
"icu_provider",
"serde",
"smallvec",
"utf16_iter",
"utf8_iter",
"write16",
"zerovec",
]
@@ -2124,6 +2197,7 @@ dependencies = [
"icu_locale_core",
"icu_properties_data",
"icu_provider",
"serde",
"zerotrie",
"zerovec",
]
@@ -2142,6 +2216,9 @@ checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73"
dependencies = [
"displaydoc",
"icu_locale_core",
"postcard",
"serde",
"stable_deref_trait",
"writeable",
"yoke",
"zerofrom",
@@ -2149,6 +2226,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"
@@ -2469,6 +2570,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"
@@ -3336,12 +3440,26 @@ dependencies = [
"portable-atomic",
]
[[package]]
name = "postcard"
version = "1.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24"
dependencies = [
"cobs",
"embedded-io 0.4.0",
"embedded-io 0.6.1",
"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",
]
@@ -5131,6 +5249,12 @@ dependencies = [
"xmlwriter",
]
[[package]]
name = "utf16_iter"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246"
[[package]]
name = "utf8_iter"
version = "1.0.4"
@@ -6081,6 +6205,12 @@ dependencies = [
"read-fonts 0.39.2",
]
[[package]]
name = "write16"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936"
[[package]]
name = "writeable"
version = "0.6.4"
@@ -6349,8 +6479,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f"
dependencies = [
"displaydoc",
"litemap",
"serde_core",
"yoke",
"zerofrom",
"zerovec",
]
[[package]]
+21 -1
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, language names, the system locale, search folding and locale sorting."
version.workspace = true
edition.workspace = true
license.workspace = true
@@ -12,6 +12,15 @@ intl_pluralrules = "=7.0.2"
unic-langid = "0.9"
log = "0.4"
sys-locale = "0.3"
icu_normalizer = "~2.3"
icu_properties = "~2.3"
# Generated data fits one ICU4X minor version; data/generate-collation.sh names its datagen.
icu_collator = { version = "~2.3.1", default-features = false, features = ["serde"], optional = true }
icu_provider = { version = "~2.3", features = ["serde"], optional = true }
icu_provider_blob = { version = "~2.3", features = ["alloc"], optional = true }
icu_provider_adapters = { version = "~2.3", optional = true }
icu_locale_core = { version = "2.3", optional = true }
icu_locale_fallback = { version = "~2.3", features = ["compiled_data"], optional = true }
unicode-bidi = "0.3"
[target.'cfg(target_arch = "wasm32")'.dependencies]
@@ -25,5 +34,16 @@ windows-registry = "0.6"
[target.'cfg(target_vendor = "apple")'.dependencies]
objc2-foundation = { version = "0.3.2", default-features = false, features = ["std", "NSDateFormatter", "NSFormatter", "NSLocale", "NSString"] }
[features]
# Locale sorting (`collate`), with ICU4X collation data for the suite's locales embedded.
collate = [
"dep:icu_collator",
"dep:icu_locale_core",
"dep:icu_locale_fallback",
"dep:icu_provider",
"dep:icu_provider_adapters",
"dep:icu_provider_blob",
]
[lints]
workspace = true
+12
View File
@@ -110,3 +110,15 @@ The app keeps its `tl!` macro, its language setting (`"auto"` → `I18N.system_l
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
complete catalog.
- **Search** (`Folder`): `Folder::new(locale).fold(text)` gives a key for case- and
accent-insensitive matching (`keep_accents()` makes accents count) and a map back to the original
text, so `Folded::find` returns the byte range to highlight. Compatibility forms fold to their
plain letters (`fi`, full-width `A`), `ß` to `ss`, `ς` to `σ`, digits of every script to ASCII
(`Layer ١٢` finds `layer 12`); ignoring accents, Persian and Arabic letter forms match (`ک` and
`ك`, `ی` and `ي`, `ة` and `ه`, `أ` and `ا`); invisible format characters (soft hyphens, joiners,
bidi isolates) are dropped. Turkish and Azerbaijani fold `I` to `ı` when accents count; ignoring
accents, `I`, `i`, `İ` and `ı` all match.
- **Sorting** (`Collator`, feature `collate`): ICU4X collation for the locales craft-i18n ships,
Persian and Hindi, with digits compared by value (`Layer 2` before `Layer 10`) and `-u-co-pinyin`
/ `-u-co-stroke` for Chinese. The data (754 KB) is regenerated by `data/generate-collation.sh`;
the feature adds about 1 MB to a release binary or wasm module (320 KB gzipped).
Binary file not shown.
+15
View File
@@ -0,0 +1,15 @@
#!/bin/sh
# Regenerates the collation data `collate` embeds, a postcard blob for the locales craft-i18n
# ships (its `locales/` directory) plus English, Persian and Hindi: each locale's default order,
# and Chinese pinyin and stroke orders.
# Needs icu4x-datagen at the version of the icu_collator dependency:
# cargo install icu4x-datagen --version 2.3.0 --locked
set -eu
cd "$(dirname "$0")"
icu4x-datagen --format blob --overwrite --out collation.postcard --deduplication maximal \
--cldr-tag 48.2.1 --icuexport-tag release-78.1rc \
--attribute-filter 'collator=/^(stroke|pinyin)?$/' \
--markers CollationRootV1 CollationTailoringV1 CollationDiacriticsV1 CollationJamoV1 \
CollationMetadataV1 CollationReorderingV1 CollationSpecialPrimariesV1 \
--locales en ar bg cs da de el es et fa fi fr hi hr hu id it ja ka kk ko lt nl pl pt-BR ru sl \
sr sr-Latn sv te tr uk vi zh-Hans zh-Hant
+234
View File
@@ -0,0 +1,234 @@
//! Locale sorting for lists people see: fonts, layers, presets, files.
//!
//! ICU4X collation with data embedded for the locales craft-i18n ships (regenerated by
//! `data/generate-collation.sh`). A locale without data of its own sorts in the Unicode root
//! order, which suits most languages that have no tailoring.
use std::cmp::Ordering;
use icu_collator::CollatorPreferences;
use icu_collator::options::CollatorOptions;
use icu_collator::preferences::CollationNumericOrdering;
use icu_collator::provider::{
CollationDiacriticsV1, CollationJamoV1, CollationMetadataV1, CollationReorderingV1, CollationRootV1, CollationSpecialPrimariesV1,
CollationTailoringV1,
};
use icu_locale_core::Locale;
use icu_locale_fallback::LocaleFallbacker;
use icu_normalizer::provider::{NormalizerNfdDataV1, NormalizerNfdTablesV1};
use icu_provider::buf::AsDeserializingBufferProvider;
use icu_provider::prelude::*;
use icu_provider_adapters::fallback::LocaleFallbackProvider;
use icu_provider_blob::BlobDataProvider;
/// Compares text in one locale's order, with runs of digits by their value: `Layer 2` before
/// `Layer 10`.
#[derive(Debug)]
pub struct Collator(Option<icu_collator::Collator>);
impl Collator {
/// Sorting for `locale`, a language code such as `sv`, `pt-br` or `zh-hant` (any case, `-`
/// or `_`, glibc forms such as `sv_SE.UTF-8` and `sr_RS@latin`). A BCP 47 collation type
/// picks another order where there is one: `zh-hant-u-co-pinyin`, `zh-hans-u-co-stroke`.
pub fn new(locale: &str) -> Collator {
let mut prefs: CollatorPreferences = Locale::try_from_str(&bcp47(locale)).unwrap_or(Locale::UNKNOWN).into();
prefs.numeric_ordering = Some(CollationNumericOrdering::True);
let options = CollatorOptions::default();
let collator = Data::new().ok().and_then(|data| {
icu_collator::Collator::try_new_unstable(&data, prefs, options)
.or_else(|_| icu_collator::Collator::try_new_unstable(&data, CollatorPreferences::default(), options))
.ok()
});
Collator(collator)
}
/// `a` against `b`. Text that differs only in case or accents still has an order, and text
/// the collation counts as equal (`Layer 2` and `Layer 02`) is in code point order, so a sort
/// doesn't depend on the input order. Without collation data (never with the embedded data)
/// this is code point order.
pub fn compare(&self, a: &str, b: &str) -> Ordering {
match &self.0 {
Some(c) => c.as_borrowed().compare(a, b).then_with(|| a.cmp(b)),
None => a.cmp(b),
}
}
/// Sort `items` by the text `key` gives each one.
pub fn sort_by_key<T>(&self, items: &mut [T], key: impl Fn(&T) -> &str) {
items.sort_by(|a, b| self.compare(key(a), key(b)));
}
}
/// `locale` as a BCP 47 tag: `_` → `-`, a glibc codeset dropped and its script modifier made a
/// subtag (`sr_RS.UTF-8@latin` → `sr-latn-RS`).
fn bcp47(locale: &str) -> String {
let locale = locale.trim();
let mut parts: Vec<&str> = locale.split(['.', '@']).next().unwrap_or_default().split(['-', '_']).collect();
let script = match locale.split_once('@').map(|(_, m)| m.to_ascii_lowercase()).as_deref() {
Some("latin") => Some("latn"),
Some("cyrillic") => Some("cyrl"),
_ => None,
};
let has_script = parts.get(1).is_some_and(|p| p.len() == 4 && p.bytes().all(|b| b.is_ascii_alphabetic()));
if let Some(script) = script
&& !has_script
{
parts.insert(1.min(parts.len()), script);
}
parts.join("-")
}
/// The embedded collation data, with locale fallback, and the decomposition tables the collator
/// also needs taken from `icu_normalizer`'s compiled data rather than embedded a second time.
struct Data(LocaleFallbackProvider<BlobDataProvider>);
impl Data {
fn new() -> Result<Data, DataError> {
let blob = BlobDataProvider::try_new_from_static_blob(include_bytes!("../data/collation.postcard"))?;
Ok(Data(LocaleFallbackProvider::new(blob, LocaleFallbacker::new().static_to_owned())))
}
}
macro_rules! from_blob {
($($marker:ty),*) => {$(
impl DataProvider<$marker> for Data {
fn load(&self, req: DataRequest) -> Result<DataResponse<$marker>, DataError> {
self.0.as_deserializing().load(req)
}
}
)*};
}
from_blob!(
CollationRootV1,
CollationTailoringV1,
CollationDiacriticsV1,
CollationJamoV1,
CollationMetadataV1,
CollationReorderingV1,
CollationSpecialPrimariesV1
);
impl DataProvider<NormalizerNfdDataV1> for Data {
fn load(&self, req: DataRequest) -> Result<DataResponse<NormalizerNfdDataV1>, DataError> {
icu_normalizer::provider::Baked.load(req)
}
}
impl DataProvider<NormalizerNfdTablesV1> for Data {
fn load(&self, req: DataRequest) -> Result<DataResponse<NormalizerNfdTablesV1>, DataError> {
icu_normalizer::provider::Baked.load(req)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sorted<'a>(locale: &str, items: &[&'a str]) -> Vec<&'a str> {
let mut v = items.to_vec();
Collator::new(locale).sort_by_key(&mut v, |s| s);
v
}
/// Each tailored locale puts a pair in another order than the root collation does, which a
/// locale whose data is missing (it falls back to root) wouldn't.
#[test]
fn every_shipped_locale_has_its_data() {
let root = Collator::new("und");
for (locale, first, second) in [
("ar", "ب", "a"),
("bg", "б", "a"),
("cs", "hrad", "chata"),
("da", "zebra", "år"),
("el", "β", "a"),
("es", "nube", "ñu"),
("et", "wc", "õun"),
("fa", "ب", "a"),
("fi", "zeta", "äiti"),
("hi", "क", "a"),
("hr", "cvijet", "čaj"),
("hu", "cukor", "csak"),
("ja", "亜", "一"),
("ka", "ა", "a"),
("kk", "б", "a"),
("ko", "가", "a"),
("lt", "yra", "jau"),
("pl", "az", "ąb"),
("ru", "б", "a"),
("sl", "cvet", "čaj"),
("sr", "б", "a"),
("sr-latn", "cvijet", "čaj"),
("sv", "zebra", "år"),
("te", "క", "a"),
("tr", "cz", "ça"),
("uk", "б", "a"),
("vi", "az", "ăn"),
("zh-hans", "阿", "中"),
("zh-hant", "人", "丈"),
] {
assert_eq!(Collator::new(locale).compare(first, second), Ordering::Less, "{locale}: {first} < {second}");
assert_eq!(root.compare(first, second), Ordering::Greater, "root: {second} < {first}");
}
// CLDR doesn't tailor these: they sort in the root order.
for locale in ["en", "de", "fr", "id", "it", "nl", "pt-br", "not a locale"] {
let collator = Collator::new(locale);
assert!(collator.0.is_some(), "{locale}");
assert_eq!(collator.compare("čaj", "cvijet"), Ordering::Less, "{locale}");
}
}
#[test]
fn digits_sort_by_value() {
let layers = ["Layer 10", "Layer 2", "layer 1", "Layer 1", "Layer 2b", "Layer 02"];
assert_eq!(sorted("en", &layers), ["layer 1", "Layer 1", "Layer 02", "Layer 2", "Layer 2b", "Layer 10"]);
let reversed: Vec<&str> = layers.iter().rev().copied().collect();
assert_eq!(sorted("en", &reversed), sorted("en", &layers));
assert_eq!(sorted("de", &["Ebene ٣", "Ebene 10", "Ebene 2"]), ["Ebene 2", "Ebene ٣", "Ebene 10"]);
}
#[test]
fn locales_sort_their_own_letters() {
let words = ["öl", "zebra", "år", "äpple", "apa", "oxe"];
assert_eq!(sorted("sv", &words), ["apa", "oxe", "zebra", "år", "äpple", "öl"]);
assert_eq!(sorted("de", &words), ["apa", "äpple", "år", "öl", "oxe", "zebra"]);
assert_eq!(sorted("de", &["Muller", "Müller", "Mueller", "Mahler"]), ["Mahler", "Mueller", "Muller", "Müller"]);
assert_eq!(
sorted("tr", &["şeker", "sabun", "ılık", "iğne", "çay", "cam", "uzun", "üzüm"]),
["cam", "çay", "ılık", "iğne", "sabun", "şeker", "uzun", "üzüm"]
);
assert_eq!(sorted("en", &["ılık", "iğne", "cam", "çay"]), ["cam", "çay", "iğne", "ılık"]);
assert_eq!(sorted("es", &["ñu", "nube", "oso"]), ["nube", "ñu", "oso"]);
// Japanese: Latin, then kana in gojūon order (hiragana and katakana together), then kanji.
assert_eq!(sorted("ja", &["漢字", "カメラ", "あめ", "いぬ", "Zoom"]), ["Zoom", "あめ", "いぬ", "カメラ", "漢字"]);
}
#[test]
fn glibc_locale_names() {
let words = ["öl", "zebra", "år", "äpple", "apa", "oxe"];
assert_eq!(sorted("sv_SE.UTF-8", &words), sorted("sv", &words));
assert_ne!(sorted("sv_SE.UTF-8", &words), sorted("en", &words));
let cities = ["天津", "北京", "上海"];
assert_eq!(sorted("zh_TW.UTF-8", &cities), sorted("zh-hant", &cities));
assert_eq!(sorted("zh_CN.UTF-8", &cities), sorted("zh-hans", &cities));
// Serbian Cyrillic sorts Cyrillic first; Latin Serbian sorts Latin first, `lj` after `lu`.
let mixed = ["ljubav", "lula", "абв"];
assert_eq!(sorted("sr_RS.UTF-8", &mixed), ["абв", "ljubav", "lula"]);
assert_eq!(sorted("sr_RS.UTF-8@latin", &mixed), ["lula", "ljubav", "абв"]);
assert_eq!(bcp47("sr_RS.UTF-8@latin"), "sr-latn-RS");
assert_eq!(bcp47("sr_Latn_RS@latin"), "sr-Latn-RS");
assert_eq!(bcp47("de_DE@euro"), "de-DE");
}
#[test]
fn chinese_sorts_by_pinyin_or_stroke() {
// 北 běi (5 strokes), 上 shàng (3), 天 tiān (4).
let cities = ["天津", "北京", "上海"];
let pinyin = ["北京", "上海", "天津"];
let stroke = ["上海", "天津", "北京"];
assert_eq!(sorted("zh-hans", &cities), pinyin);
assert_eq!(sorted("zh-hant", &cities), stroke);
assert_eq!(sorted("zh-hans-u-co-stroke", &cities), stroke);
assert_eq!(sorted("zh-hant-u-co-pinyin", &cities), pinyin);
}
}
+424
View File
@@ -0,0 +1,424 @@
//! Case- and accent-insensitive matching for search fields, with a map back to the original text
//! for highlighting.
//!
//! [`Folder::fold`] turns text into a search key: compatibility forms decomposed (`fi` → `fi`,
//! full-width `A` → `a`, `㎏` → `kg`), lower case, `ß` → `ss`, final `ς` → `σ`, and with accents
//! ignored (the default) combining accents, Hebrew points and Arabic vowel marks dropped and the
//! few letters with built-in strokes mapped to their base (`ø`, `ł`, `đ`, `ı`, `æ` → `ae`), and
//! Persian letter forms mapped to the Arabic ones (`ک` → `ك`, `ی` → `ي`, `ة` → `ه`). Decimal
//! digits of every script fold to ASCII (`١٢` → `12`). Invisible format characters (soft
//! hyphens, zero-width joiners, bidi isolates) are dropped too.
//!
//! Dotted and dotless i follow the locale when accents count. In Turkish and Azerbaijani, `I`
//! folds to `ı` and `İ` to `i`; elsewhere both fold to `i` and `ı` stays a letter of its own.
//! Ignoring accents, all four fold to `i`, so `istanbul` finds `İSTANBUL` and `irmak` finds
//! `IRMAK` and `ırmak`. Plain `str::to_lowercase` follows no locale and turns `İ` into `i` plus a
//! combining dot, which nothing typed would match.
//!
//! The Turkish i handling and sigma folding come from PdfCraft's `fold_case` (snipeTR,
//! nevicia2010-hub).
use std::ops::Range;
use icu_normalizer::DecomposingNormalizerBorrowed;
use icu_properties::props::{GraphemeExtend, HangulSyllableType};
use icu_properties::{CodePointMapData, CodePointSetData};
/// Folds text for one locale. Cheap to build and copy.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Folder {
turkic: bool,
accents: bool,
}
impl Folder {
/// Case- and accent-insensitive folding for `locale`, a language code such as `tr`, `pt-br`
/// or `zh-hant` (any case, `-` or `_`).
pub fn new(locale: &str) -> Folder {
let language = locale.split(['-', '_', '.', '@']).next().unwrap_or_default();
Folder { turkic: language.eq_ignore_ascii_case("tr") || language.eq_ignore_ascii_case("az"), accents: false }
}
/// Accents count: `é` no longer matches `e`, and a match can't end on a letter whose accent it
/// leaves out.
pub fn keep_accents(self) -> Folder {
Folder { accents: true, ..self }
}
/// `s` folded, with the map back to `s`.
pub fn fold(&self, s: &str) -> Folded {
let nfkd = DecomposingNormalizerBorrowed::new_nfkd();
let extend = CodePointSetData::new::<GraphemeExtend>();
let hangul = CodePointMapData::<HangulSyllableType>::new();
let mut text = String::with_capacity(s.len());
let mut starts = Vec::new();
// A cluster is a character and the combining marks after it, folded and mapped as one;
// conjoining jamo that spell one syllable are one cluster too.
let mut cluster_start = 0;
let mut chars = s.char_indices();
let mut prev = chars.next().map(|(_, c)| hangul.get(c));
for (i, c) in chars {
let kind = hangul.get(c);
if !extend.contains(c) && !prev.is_some_and(|p| continues_syllable(p, kind)) {
starts.push((text.len(), cluster_start));
self.fold_cluster(&nfkd.normalize(&s[cluster_start..i]), &mut text);
cluster_start = i;
}
prev = Some(kind);
}
if !s.is_empty() {
starts.push((text.len(), cluster_start));
self.fold_cluster(&nfkd.normalize(&s[cluster_start..]), &mut text);
}
starts.push((text.len(), s.len()));
Folded { text, starts }
}
/// `s` folded, without the map: a key to compare or store.
pub fn key(&self, s: &str) -> String {
self.fold(s).text
}
/// Whether `haystack` contains `needle`, both folded.
pub fn contains(&self, haystack: &str, needle: &str) -> bool {
self.fold(haystack).find(&self.fold(needle)).is_some()
}
/// One cluster, already in NFKD.
fn fold_cluster(&self, cluster: &str, out: &mut String) {
let mut chars = cluster.chars().peekable();
while let Some(c) = chars.next() {
match c {
// `İ` decomposes to `I` and a combining dot above: the dot of a lower-case `i`.
'I' if chars.peek() == Some(&'\u{307}') => {
chars.next();
out.push('i');
}
'I' if self.turkic && self.accents => out.push('ı'),
// Persian heh with hamza above (ۀ) decomposes to ae (ە) and hamza.
'\u{6D5}' if !self.accents && chars.peek() == Some(&'\u{654}') => {
chars.next();
out.push('ه');
}
'ß' | 'ẞ' => out.push_str("ss"),
c if let Some(d) = ascii_digit(c) => out.push(d),
'Σ' | 'σ' | 'ς' => out.push('σ'),
c if ignorable(c) || (!self.accents && accent(c)) => {}
c => {
for l in c.to_lowercase() {
match base_letter(l) {
Some(base) if !self.accents => out.push_str(base),
_ => out.push(l),
}
}
}
}
}
}
}
/// The base of a lower-case letter whose diacritic is built in rather than combining, and the
/// Arabic form of a letter that Persian and Urdu write differently: keheh (ک) is kaf (ك), Farsi
/// yeh (ی) and alef maksura (ى) are yeh (ي), teh marbuta (ة) is heh (ه), alef wasla (ٱ) is alef.
fn base_letter(c: char) -> Option<&'static str> {
Some(match c {
'ک' => "ك",
'ی' | 'ى' => "ي",
'ة' => "ه",
'ٱ' => "ا",
'ı' => "i",
'ø' => "o",
'ł' => "l",
'đ' => "d",
'ħ' => "h",
'ŧ' => "t",
'æ' => "ae",
'œ' => "oe",
_ => return None,
})
}
/// Invisible characters that only steer layout: soft hyphen, Arabic letter mark, the kashida
/// that stretches Arabic words, Mongolian variation selectors, zero-width space and joiners, direction marks, embeddings and isolates,
/// word joiner, variation selectors, byte order mark and tags.
fn ignorable(c: char) -> bool {
matches!(c,
'\u{AD}' | '\u{61C}' | '\u{640}' | '\u{180B}'..='\u{180F}' | '\u{200B}'..='\u{200F}' | '\u{202A}'..='\u{202E}'
| '\u{2060}'..='\u{206F}' | '\u{FE00}'..='\u{FE0F}' | '\u{FEFF}' | '\u{E0000}'..='\u{E0FFF}')
}
/// Marks that searching without accents ignores: Latin, Greek and Cyrillic combining diacritics,
/// Hebrew points and cantillation, Arabic vowel marks.
/// Kana voicing marks, Indic and Thai vowel signs are letters' parts, not accents, and stay.
fn accent(c: char) -> bool {
matches!(c,
'\u{300}'..='\u{36F}' | '\u{1AB0}'..='\u{1AFF}' | '\u{1DC0}'..='\u{1DFF}' | '\u{20D0}'..='\u{20FF}'
| '\u{FE20}'..='\u{FE2F}'
| '\u{591}'..='\u{5BD}' | '\u{5BF}' | '\u{5C1}' | '\u{5C2}' | '\u{5C4}' | '\u{5C5}' | '\u{5C7}'
| '\u{610}'..='\u{61A}' | '\u{64B}'..='\u{65F}' | '\u{670}' | '\u{6D6}'..='\u{6DC}'
| '\u{6DF}'..='\u{6E4}' | '\u{6E7}' | '\u{6E8}' | '\u{6EA}'..='\u{6ED}')
}
/// The first code point of each run of ten Unicode decimal digits (general category Nd), Unicode
/// 16.0.
const ZEROS: [u32; 76] = [
0x30, 0x660, 0x6F0, 0x7C0, 0x966, 0x9E6, 0xA66, 0xAE6, 0xB66, 0xBE6, 0xC66, 0xCE6, 0xD66, 0xDE6, 0xE50, 0xED0, 0xF20, 0x1040, 0x1090, 0x17E0,
0x1810, 0x1946, 0x19D0, 0x1A80, 0x1A90, 0x1B50, 0x1BB0, 0x1C40, 0x1C50, 0xA620, 0xA8D0, 0xA900, 0xA9D0, 0xA9F0, 0xAA50, 0xABF0, 0xFF10, 0x104A0,
0x10D30, 0x10D40, 0x11066, 0x110F0, 0x11136, 0x111D0, 0x112F0, 0x11450, 0x114D0, 0x11650, 0x116C0, 0x116D0, 0x116DA, 0x11730, 0x118E0, 0x11950,
0x11BF0, 0x11C50, 0x11D50, 0x11DA0, 0x11F50, 0x16130, 0x16A60, 0x16AC0, 0x16B50, 0x16D70, 0x1CCF0, 0x1D7CE, 0x1D7D8, 0x1D7E2, 0x1D7EC, 0x1D7F6,
0x1E140, 0x1E2F0, 0x1E4F0, 0x1E5F1, 0x1E950, 0x1FBF0,
];
/// A decimal digit of any script as its ASCII digit: `١٢` and `१२` search as `12`.
fn ascii_digit(c: char) -> Option<char> {
let c = u32::from(c);
let zero = match ZEROS.binary_search(&c) {
Ok(i) => ZEROS.get(i),
Err(i) => ZEROS.get(i.checked_sub(1)?),
}?;
let value = c - zero;
if value < 10 { char::from_digit(value, 10) } else { None }
}
/// Whether a conjoining jamo of kind `next` continues the syllable a character of kind `prev`
/// ends: a vowel after a leading consonant, a final consonant after a vowel or an open syllable
/// (`가` and `ᆨ` spell `각`).
fn continues_syllable(prev: HangulSyllableType, next: HangulSyllableType) -> bool {
matches!(
(prev, next),
(HangulSyllableType::LeadingJamo, HangulSyllableType::VowelJamo)
| (HangulSyllableType::VowelJamo | HangulSyllableType::LVSyllable, HangulSyllableType::TrailingJamo)
)
}
/// Folded text and where each part came from.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Folded {
text: String,
/// One (folded offset, source offset) per source cluster, in order, then both lengths. A
/// cluster folds to the text up to the next entry's folded offset, possibly none.
starts: Vec<(usize, usize)>,
}
impl Folded {
/// The folded text.
pub fn as_str(&self) -> &str {
&self.text
}
/// The source byte range that folded bytes `range` came from, widened to whole source
/// characters with their accents: `ss` in the folding of `Straße` maps back to `ß`, `e` in
/// that of `é` to the whole `é`. An empty range maps to an empty one.
pub fn source_range(&self, range: Range<usize>) -> Range<usize> {
// The last cluster starting at or before `at` is the one holding it: clusters that fold to
// nothing share their offset with the next one.
let cluster = |at: usize| self.starts.partition_point(|&(f, _)| f <= at).saturating_sub(1);
if range.is_empty() {
let s = self.starts[cluster(range.start)].1;
return s..s;
}
let first = cluster(range.start);
let last = cluster(range.end - 1);
self.starts[first].1..self.starts[(last + 1).min(self.starts.len() - 1)].1
}
/// The first match of `needle` (folded the same way), as a byte range of the source text.
/// An empty needle matches at the start.
pub fn find(&self, needle: &Folded) -> Option<Range<usize>> {
self.matches(needle).next()
}
/// Every non-overlapping match of `needle` (folded the same way), as byte ranges of the
/// source text. A match may start or end inside an expansion (`ss` of `ß`, `fi` of `fi`) but
/// not leave out a mark that follows a letter, so `re` doesn't match `ré` when accents count.
pub fn matches<'a>(&'a self, needle: &'a Folded) -> impl Iterator<Item = Range<usize>> + 'a {
let extend = CodePointSetData::new::<GraphemeExtend>();
let hangul = CodePointMapData::<HangulSyllableType>::new();
// The vowel and final consonant of a decomposed Hangul syllable continue it, like a mark,
// but only inside one source cluster: standalone jamo (`ㅋㅠ`, `ㅠㅠ`) are letters.
let starts_mark = move |at: usize| {
let Some(c) = self.text[at..].chars().next() else { return false };
if extend.contains(c) {
return true;
}
let inside = self.starts.binary_search_by_key(&at, |&(f, _)| f).is_err();
inside && self.text[..at].chars().next_back().is_some_and(|p| continues_syllable(hangul.get(p), hangul.get(c)))
};
let n = needle.text.as_str();
let mut from = 0;
let mut done = false;
std::iter::from_fn(move || {
if done {
return None;
}
if n.is_empty() {
done = true;
return Some(0..0);
}
while let Some(i) = self.text.get(from..).and_then(|t| t.find(n)) {
let start = from + i;
let end = start + n.len();
if !starts_mark(start) && !starts_mark(end) {
from = end;
return Some(self.source_range(start..end));
}
from = start + self.text[start..].chars().next().map_or(1, char::len_utf8);
}
done = true;
None
})
}
}
#[cfg(test)]
mod tests {
use super::*;
fn key(locale: &str, s: &str) -> String {
Folder::new(locale).key(s)
}
fn key_accents(locale: &str, s: &str) -> String {
Folder::new(locale).keep_accents().key(s)
}
#[test]
fn folding_table() {
let nfd = |s: &str| DecomposingNormalizerBorrowed::new_nfd().normalize(s).into_owned();
// (locale, input, ignoring accents, keeping accents)
let table = [
("en", "Hello WORLD", "hello world".to_owned(), "hello world".to_owned()),
("en", "İSTANBUL", "istanbul".into(), "istanbul".into()),
("tr", "İSTANBUL", "istanbul".into(), "istanbul".into()),
("en", "IRMAK ırmak", "irmak irmak".into(), "irmak ırmak".into()),
("tr", "IRMAK ırmak", "irmak irmak".into(), "ırmak ırmak".into()),
("az", "İIıi", "iiii".into(), "iııi".into()),
("tr-TR", "Şişli", "sisli".into(), nfd("şişli")),
("de", "Straße STRAẞE", "strasse strasse".into(), "strasse strasse".into()),
("el", "ΟΔΥΣΣΕΥΣ οδυσσευς", "οδυσσευσ οδυσσευσ".into(), "οδυσσευσ οδυσσευσ".into()),
("el", "Ἀθῆναι", "αθηναι".into(), nfd("ἀθῆναι")),
("fr", "Œuvre ÉTÉ", "oeuvre ete".into(), nfd("œuvre été")),
("da", "Ærø", "aero".into(), nfd("ærø")),
("pl", "Łódź", "lodz".into(), nfd("łódź")),
("vi", "Đà Nẵng", "da nang".into(), nfd("đà nẵng")),
("en", "final ff", "final ff".into(), "final ff".into()),
("ja", "ABC123 ガ", nfd("abc123 ガ"), nfd("abc123 ガ")),
("ar", "مُحَمَّد أحمـــد", nfd("محمد احمد"), nfd("مُحَمَّد أحمد")),
("he", "שָׁלוֹם", "שלום".into(), nfd("שָׁלוֹם")),
("en", "soft\u{AD}ware \u{2068}name\u{2069}", "software name".into(), "software name".into()),
("ru", "Ёлка", "елка".into(), nfd("ёлка")),
("ar", "Layer ١٢ ۳۴ १२", "layer 12 34 12".into(), "layer 12 34 12".into()),
("fa", "کتاب یک", "كتاب يك".into(), "کتاب یک".into()),
("fa", "خانۀ", "خانه".into(), nfd("خانۀ")),
("ar", "مدرسة على إسلام آمن", "مدرسه علي اسلام امن".into(), nfd("مدرسة على إسلام آمن")),
];
for (locale, input, ignoring, keeping) in table {
assert_eq!(key(locale, input), ignoring, "{locale} {input}");
assert_eq!(key_accents(locale, input), keeping, "{locale} {input}, accents kept");
}
}
#[test]
fn turkish_i_depends_on_locale_when_accents_count() {
let tr = Folder::new("tr").keep_accents();
let en = Folder::new("en").keep_accents();
assert!(tr.contains("IRMAK", "ırmak") && !tr.contains("IRMAK", "irmak"));
assert!(en.contains("IRMAK", "irmak") && !en.contains("IRMAK", "ırmak"));
assert!(tr.contains("İSTANBUL", "istanbul") && en.contains("İSTANBUL", "istanbul"));
for locale in ["tr", "en"] {
let f = Folder::new(locale);
assert!(f.contains("IRMAK", "ırmak") && f.contains("ırmak", "IRMAK") && f.contains("İzmir", "izmir"));
}
}
#[test]
fn matches_map_back_to_the_source() {
let f = Folder::new("en");
fn find<'a>(f: Folder, haystack: &'a str, needle: &str) -> Option<&'a str> {
f.fold(haystack).find(&f.fold(needle)).map(|r| &haystack[r])
}
let find = |haystack, needle| find(f, haystack, needle);
assert_eq!(find("Große Straße", "strasse"), Some("Straße"));
assert_eq!(find("Große Straße", "SS"), Some("ß"));
assert_eq!(find("Große Straße", "sse"), Some("ße"));
assert_eq!(find("Café crème", "creme"), Some("crème"));
assert_eq!(find("Cafe\u{301} noir", "cafe"), Some("Cafe\u{301}"));
assert_eq!(find("File 2", "file 2"), Some("File 2"));
assert_eq!(find("the final cut", "fin"), Some("fin"));
assert_eq!(find("Layer \u{2068}İSTANBUL\u{2069}", "istanbul"), Some("İSTANBUL"));
assert_eq!(find("abc", "x"), None);
assert_eq!(find("abc", ""), Some(""));
let all: Vec<_> = f.fold("Ab ab AB").matches(&f.fold("ab")).collect();
assert_eq!(all, [0..2, 3..5, 6..8]);
}
#[test]
fn persian_and_arabic_letters_match() {
let f = Folder::new("fa");
// Persian keheh and Farsi yeh against Arabic kaf and yeh, and hamza forms of alef.
assert!(f.contains("كتاب عربي", "کتاب عربی"));
assert!(f.contains("کتاب عربی", "كتاب عربي"));
assert!(f.contains("أحمد إبراهيم", "احمد ابراهیم"));
assert!(f.contains("مدرسة", "مدرسه"));
assert_eq!(f.fold("Layer ١٢").find(&f.fold("layer 12")), Some(0.."Layer ١٢".len()));
assert!(Folder::new("en").keep_accents().contains("Layer ١٢", "layer 12"));
let digits = ZEROS.iter().map(|&zero| char::from_u32(zero + 7).unwrap());
assert!(digits.clone().all(|d| d.is_numeric() && ascii_digit(d) == Some('7')));
assert_eq!(ascii_digit('\u{1D800}'), None);
assert_eq!(ascii_digit('/'), None);
}
#[test]
fn accents_count_only_whole_letters() {
let f = Folder::new("fr").keep_accents();
assert!(!f.contains("ré", "re"));
assert!(f.contains("ré", "RÉ") && f.contains("re\u{301}", "ré"));
assert!(f.contains("ré", "é"));
// Kana voicing marks stay even with accents ignored: か doesn't find が.
assert!(!Folder::new("ja").contains("が", "か"));
// Nor does a Hangul syllable without its final consonant: 하 doesn't find 한.
let ko = Folder::new("ko");
assert!(!ko.contains("한국", "하") && !ko.keep_accents().contains("한국", "하"));
assert!(ko.contains("한국", "한") && ko.contains("하늘", "하"));
// Conjoining jamo spell the same syllable; compatibility jamo stand alone.
assert!(!ko.contains("\u{1112}\u{1161}\u{11AB}", "하") && ko.contains("\u{1112}\u{1161}\u{11AB}", "한"));
assert!(!ko.contains("하\u{11AB}", "하"));
for (haystack, needle) in [("ㅠㅠ", "ㅠ"), ("ㅠㅠ", "ㅠㅠ"), ("ㅋㅠ", "ㅋ"), ("ㅋㅠ", "ㅠ"), ("ㅋㅋㅋ", "ㅋㅋ"), ("ㄳ", "ㄳ"), ("값ㄳ", "ㄳ")]
{
assert!(ko.contains(haystack, needle), "{haystack} {needle}");
}
assert_eq!(ko.fold("ㅋㅠㅠ").find(&ko.fold("ㅠ")).map(|r| r.start), Some("ㅋ".len()));
}
#[test]
fn source_ranges_round_trip() {
let f = Folder::new("de");
for s in ["Straße", "ffi A", "e\u{301}\u{AD}x", "", "İı", "\u{301}a"] {
let folded = f.fold(s);
let text = folded.as_str();
// A leading mark folds to nothing and has no part in any match.
let whole = if s.starts_with('\u{301}') { 2..s.len() } else { 0..s.len() };
assert_eq!(folded.source_range(0..text.len()), whole, "{s:?}");
// Every folded character maps to a non-empty source range that folds back to text
// containing it.
for (i, c) in text.char_indices() {
let r = folded.source_range(i..i + c.len_utf8());
assert!(!r.is_empty() && f.key(&s[r.clone()]).contains(c), "{s:?} {i} {r:?}");
}
}
}
#[test]
fn folding_is_idempotent() {
// A folded query folds to itself, so a stored key can be searched with a fresh fold.
for locale in ["en", "tr"] {
for f in [Folder::new(locale), Folder::new(locale).keep_accents()] {
for c in ('\0'..='\u{FFFF}').chain('\u{1D400}'..='\u{1D7FF}') {
let once = f.key(c.encode_utf8(&mut [0; 4]));
assert_eq!(f.key(&once), once, "{locale} {:?} U+{:04X}", f, c as u32);
}
}
}
}
}
+6
View File
@@ -6,6 +6,9 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
mod catalog;
#[cfg(feature = "collate")]
mod collate;
mod fold;
mod region;
mod scripts;
mod size;
@@ -17,6 +20,9 @@ use intl_pluralrules::{PluralCategory, PluralRuleType, PluralRules};
use unic_langid::LanguageIdentifier;
use unicode_bidi::{BidiClass, bidi_class};
#[cfg(feature = "collate")]
pub use collate::Collator;
pub use fold::{Folded, Folder};
pub use region::{HourCycle, Measurement, Paper, Region};
pub use scripts::{Direction, Han, Script, base_direction, direction, han, scripts};
pub use size::{FileSize, SizeUnit};