icu_locale_core/locale.rs
1// This file is part of ICU4X. For terms of use, please see the file
2// called LICENSE at the top level of the ICU4X source tree
3// (online at: https://github.com/unicode-org/icu4x/blob/main/LICENSE ).
4
5use crate::parser::*;
6use crate::subtags::Subtag;
7use crate::{LanguageIdentifier, extensions, subtags};
8#[cfg(feature = "alloc")]
9use alloc::borrow::Cow;
10use core::cmp::Ordering;
11#[cfg(feature = "alloc")]
12use core::str::FromStr;
13
14/// A core struct representing a [`Unicode Locale Identifier`].
15///
16/// A locale is made of two parts:
17/// * Unicode Language Identifier
18/// * A set of Unicode Extensions
19///
20/// [`Locale`] exposes all of the same fields and methods as [`LanguageIdentifier`], and
21/// on top of that is able to parse, manipulate and serialize unicode extension fields.
22///
23/// # Ordering
24///
25/// This type deliberately does not implement `Ord` or `PartialOrd` because there are
26/// multiple possible orderings. Depending on your use case, two orderings are available:
27///
28/// 1. A string ordering, suitable for stable serialization: [`Locale::strict_cmp`]
29/// 2. A struct ordering, suitable for use with a `BTreeSet`: [`Locale::total_cmp`]
30///
31/// See issue: <https://github.com/unicode-org/icu4x/issues/1215>
32///
33/// # Parsing
34///
35/// Unicode recognizes three levels of standard conformance for a locale:
36///
37/// * *well-formed* - syntactically correct
38/// * *valid* - well-formed and only uses registered language subtags, extensions, keywords, types...
39/// * *canonical* - valid and no deprecated codes or structure.
40///
41/// Any syntactically invalid subtags will cause the parsing to fail with an error.
42///
43/// This operation normalizes syntax to be well-formed. No legacy subtag replacements is performed.
44/// For validation and canonicalization, see `LocaleCanonicalizer`.
45///
46/// ICU4X's Locale parsing does not allow for non-BCP-47-compatible locales [allowed by UTS 35 for backwards compatability][tr35-bcp].
47/// Furthermore, it currently does not allow for language tags to have more than three characters.
48///
49/// # Serde
50///
51/// This type implements `serde::Serialize` and `serde::Deserialize` if the
52/// `"serde"` Cargo feature is enabled on the crate.
53///
54/// The value will be serialized as a string and parsed when deserialized.
55/// For tips on efficient storage and retrieval of locales, see [`crate::zerovec`].
56///
57/// # Examples
58///
59/// Simple example:
60///
61/// ```
62/// use icu::locale::{
63/// extensions::unicode::{key, value},
64/// locale,
65/// subtags::{language, region},
66/// };
67///
68/// let loc = locale!("en-US-u-ca-buddhist");
69///
70/// assert_eq!(loc.id.language, language!("en"));
71/// assert_eq!(loc.id.script, None);
72/// assert_eq!(loc.id.region, Some(region!("US")));
73/// assert_eq!(loc.id.variants.len(), 0);
74/// assert_eq!(
75/// loc.extensions.unicode.keywords.get(&key!("ca")),
76/// Some(&value!("buddhist"))
77/// );
78/// ```
79///
80/// More complex example:
81///
82/// ```
83/// use icu::locale::{Locale, subtags::*};
84///
85/// let loc: Locale = "eN-latn-Us-Valencia-u-hC-H12"
86/// .parse()
87/// .expect("Failed to parse.");
88///
89/// assert_eq!(loc.id.language, "en".parse::<Language>().unwrap());
90/// assert_eq!(loc.id.script, "Latn".parse::<Script>().ok());
91/// assert_eq!(loc.id.region, "US".parse::<Region>().ok());
92/// assert_eq!(
93/// loc.id.variants.first(),
94/// "valencia".parse::<Variant>().ok().as_ref()
95/// );
96/// ```
97///
98/// [`Unicode Locale Identifier`]: https://unicode.org/reports/tr35/tr35.html#Unicode_locale_identifier
99/// [tr35-bcp]: https://unicode.org/reports/tr35/#BCP_47_Conformance
100#[derive(PartialEq, Eq, Clone, Hash)] // no Ord or PartialOrd: see docs
101#[allow(clippy::exhaustive_structs)] // This struct is stable (and invoked by a macro)
102pub struct Locale {
103 /// The basic language/script/region components in the locale identifier along with any variants.
104 pub id: LanguageIdentifier,
105 /// Any extensions present in the locale identifier.
106 pub extensions: extensions::Extensions,
107}
108
109#[test]
110// Expected sizes are based on a 64-bit architecture
111#[cfg(target_pointer_width = "64")]
112fn test_sizes() {
113 assert_eq!(size_of::<subtags::Language>(), 3);
114 assert_eq!(size_of::<subtags::Script>(), 4);
115 assert_eq!(size_of::<subtags::Region>(), 3);
116 assert_eq!(size_of::<subtags::Variant>(), 8);
117 assert_eq!(size_of::<subtags::Variants>(), 16);
118 assert_eq!(size_of::<LanguageIdentifier>(), 32);
119
120 assert_eq!(size_of::<extensions::transform::Transform>(), 56);
121 assert_eq!(size_of::<Option<LanguageIdentifier>>(), 32);
122 assert_eq!(size_of::<extensions::transform::Fields>(), 24);
123
124 assert_eq!(size_of::<extensions::unicode::Attributes>(), 16);
125 assert_eq!(size_of::<extensions::unicode::Keywords>(), 24);
126 assert_eq!(size_of::<Vec<extensions::other::Other>>(), 24);
127 assert_eq!(size_of::<extensions::private::Private>(), 16);
128 assert_eq!(size_of::<extensions::Extensions>(), 136);
129
130 assert_eq!(size_of::<Locale>(), 168);
131}
132
133impl Locale {
134 /// The unknown locale "und".
135 pub const UNKNOWN: Self = crate::locale!("und");
136
137 /// A constructor which takes a utf8 slice, parses it and
138 /// produces a well-formed [`Locale`].
139 ///
140 /// ✨ *Enabled with the `alloc` Cargo feature.*
141 ///
142 /// Note: Support for the legacy `_` separator has been dropped since 2.0.0.
143 /// Users of ICU4X need to convert the `_` to `-` before calling the
144 /// function.
145 ///
146 /// # Examples
147 ///
148 /// ```
149 /// use icu::locale::Locale;
150 ///
151 /// Locale::try_from_str("en-US-u-hc-h12").unwrap();
152 /// ```
153 #[inline]
154 #[cfg(feature = "alloc")]
155 pub fn try_from_str(s: &str) -> Result<Self, ParseError> {
156 Self::try_from_utf8(s.as_bytes())
157 }
158
159 /// See [`Self::try_from_str`]
160 ///
161 /// ✨ *Enabled with the `alloc` Cargo feature.*
162 #[cfg(feature = "alloc")]
163 pub fn try_from_utf8(code_units: &[u8]) -> Result<Self, ParseError> {
164 parse_locale(code_units)
165 }
166
167 /// Normalize the locale (operating on UTF-8 formatted byte slices)
168 ///
169 /// This operation will normalize casing.
170 ///
171 /// ✨ *Enabled with the `alloc` Cargo feature.*
172 ///
173 /// # Examples
174 ///
175 /// ```
176 /// use icu::locale::Locale;
177 ///
178 /// assert_eq!(
179 /// Locale::normalize_utf8(b"pL-latn-pl-U-HC-H12").as_deref(),
180 /// Ok("pl-Latn-PL-u-hc-h12")
181 /// );
182 /// ```
183 #[cfg(feature = "alloc")]
184 pub fn normalize_utf8(input: &[u8]) -> Result<Cow<'_, str>, ParseError> {
185 let locale = Self::try_from_utf8(input)?;
186 Ok(writeable::to_string_or_borrow(&locale, input))
187 }
188
189 /// Normalize the locale (operating on strings)
190 ///
191 /// This operation will normalize casing.
192 ///
193 /// ✨ *Enabled with the `alloc` Cargo feature.*
194 ///
195 /// # Examples
196 ///
197 /// ```
198 /// use icu::locale::Locale;
199 ///
200 /// assert_eq!(
201 /// Locale::normalize("pL-latn-pl-U-HC-H12").as_deref(),
202 /// Ok("pl-Latn-PL-u-hc-h12")
203 /// );
204 /// ```
205 #[cfg(feature = "alloc")]
206 pub fn normalize(input: &str) -> Result<Cow<'_, str>, ParseError> {
207 Self::normalize_utf8(input.as_bytes())
208 }
209
210 /// Compare this [`Locale`] with BCP-47 bytes.
211 ///
212 /// The return value is equivalent to what would happen if you first converted this
213 /// [`Locale`] to a BCP-47 string and then performed a byte comparison.
214 ///
215 /// This function is case-sensitive and results in a *total order*, so it is appropriate for
216 /// binary search. The only argument producing [`Ordering::Equal`] is `self.to_string()`.
217 ///
218 /// # Examples
219 ///
220 /// Sorting a list of locales with this method requires converting one of them to a string:
221 ///
222 /// ```
223 /// use icu::locale::Locale;
224 /// use std::cmp::Ordering;
225 /// use writeable::Writeable;
226 ///
227 /// // Random input order:
228 /// let bcp47_strings: &[&str] = &[
229 /// "und-u-ca-hebrew",
230 /// "ar-Latn",
231 /// "zh-Hant-TW",
232 /// "zh-TW",
233 /// "und-fonipa",
234 /// "zh-Hant",
235 /// "ar-SA",
236 /// ];
237 ///
238 /// let mut locales = bcp47_strings
239 /// .iter()
240 /// .map(|s| s.parse().unwrap())
241 /// .collect::<Vec<Locale>>();
242 /// locales.sort_by(|a, b| {
243 /// let b = b.write_to_string();
244 /// a.strict_cmp(b.as_bytes())
245 /// });
246 /// let strict_cmp_strings = locales
247 /// .iter()
248 /// .map(|l| l.to_string())
249 /// .collect::<Vec<String>>();
250 ///
251 /// // Output ordering, sorted alphabetically
252 /// let expected_ordering: &[&str] = &[
253 /// "ar-Latn",
254 /// "ar-SA",
255 /// "und-fonipa",
256 /// "und-u-ca-hebrew",
257 /// "zh-Hant",
258 /// "zh-Hant-TW",
259 /// "zh-TW",
260 /// ];
261 ///
262 /// assert_eq!(expected_ordering, strict_cmp_strings);
263 /// ```
264 pub fn strict_cmp(&self, other: &[u8]) -> Ordering {
265 writeable::cmp_utf8(self, other)
266 }
267
268 #[expect(clippy::type_complexity)]
269 pub(crate) fn as_tuple(
270 &self,
271 ) -> (
272 (
273 subtags::Language,
274 Option<subtags::Script>,
275 Option<subtags::Region>,
276 &subtags::Variants,
277 ),
278 (
279 (
280 &extensions::unicode::Attributes,
281 &extensions::unicode::Keywords,
282 ),
283 (
284 Option<(
285 subtags::Language,
286 Option<subtags::Script>,
287 Option<subtags::Region>,
288 &subtags::Variants,
289 )>,
290 &extensions::transform::Fields,
291 ),
292 &extensions::private::Private,
293 &[extensions::other::Other],
294 ),
295 ) {
296 (self.id.as_tuple(), self.extensions.as_tuple())
297 }
298
299 /// Returns an ordering suitable for use in [`BTreeSet`].
300 ///
301 /// Unlike [`Locale::strict_cmp`], the ordering may or may not be equivalent
302 /// to string ordering, and it may or may not be stable across ICU4X releases.
303 ///
304 /// # Examples
305 ///
306 /// This method returns a nonsensical ordering derived from the fields of the struct:
307 ///
308 /// ```
309 /// use icu::locale::Locale;
310 /// use std::cmp::Ordering;
311 ///
312 /// // Input strings, sorted alphabetically
313 /// let bcp47_strings: &[&str] = &[
314 /// "ar-Latn",
315 /// "ar-SA",
316 /// "und-fonipa",
317 /// "und-u-ca-hebrew",
318 /// "zh-Hant",
319 /// "zh-Hant-TW",
320 /// "zh-TW",
321 /// ];
322 /// assert!(bcp47_strings.windows(2).all(|w| w[0] < w[1]));
323 ///
324 /// let mut locales = bcp47_strings
325 /// .iter()
326 /// .map(|s| s.parse().unwrap())
327 /// .collect::<Vec<Locale>>();
328 /// locales.sort_by(Locale::total_cmp);
329 /// let total_cmp_strings = locales
330 /// .iter()
331 /// .map(|l| l.to_string())
332 /// .collect::<Vec<String>>();
333 ///
334 /// // Output ordering, sorted arbitrarily
335 /// let expected_ordering: &[&str] = &[
336 /// "ar-SA",
337 /// "ar-Latn",
338 /// "und-u-ca-hebrew",
339 /// "und-fonipa",
340 /// "zh-TW",
341 /// "zh-Hant",
342 /// "zh-Hant-TW",
343 /// ];
344 ///
345 /// assert_eq!(expected_ordering, total_cmp_strings);
346 /// ```
347 ///
348 /// Use a wrapper to add a [`Locale`] to a [`BTreeSet`]:
349 ///
350 /// ```no_run
351 /// use icu::locale::Locale;
352 /// use std::cmp::Ordering;
353 /// use std::collections::BTreeSet;
354 ///
355 /// #[derive(PartialEq, Eq)]
356 /// struct LocaleTotalOrd(Locale);
357 ///
358 /// impl Ord for LocaleTotalOrd {
359 /// fn cmp(&self, other: &Self) -> Ordering {
360 /// self.0.total_cmp(&other.0)
361 /// }
362 /// }
363 ///
364 /// impl PartialOrd for LocaleTotalOrd {
365 /// fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
366 /// Some(self.cmp(other))
367 /// }
368 /// }
369 ///
370 /// let _: BTreeSet<LocaleTotalOrd> = unimplemented!();
371 /// ```
372 ///
373 /// [`BTreeSet`]: alloc::collections::BTreeSet
374 pub fn total_cmp(&self, other: &Self) -> Ordering {
375 self.as_tuple().cmp(&other.as_tuple())
376 }
377
378 /// Compare this `Locale` with a potentially unnormalized BCP-47 string.
379 ///
380 /// The return value is equivalent to what would happen if you first parsed the
381 /// BCP-47 string to a `Locale` and then performed a structural comparison.
382 ///
383 /// ✨ *Enabled with the `alloc` Cargo feature.*
384 ///
385 /// # Examples
386 ///
387 /// ```
388 /// use icu::locale::Locale;
389 ///
390 /// let bcp47_strings: &[&str] = &[
391 /// "pl-LaTn-pL",
392 /// "uNd",
393 /// "UND-FONIPA",
394 /// "UnD-t-m0-TrUe",
395 /// "uNd-u-CA-Japanese",
396 /// "ZH",
397 /// ];
398 ///
399 /// for a in bcp47_strings {
400 /// assert!(a.parse::<Locale>().unwrap().normalizing_eq(a));
401 /// }
402 /// ```
403 #[cfg(feature = "alloc")]
404 pub fn normalizing_eq(&self, other: &str) -> bool {
405 macro_rules! subtag_matches {
406 ($T:ty, $iter:ident, $expected:expr) => {
407 $iter
408 .next()
409 .map(|b| <$T>::try_from_utf8(b) == Ok($expected))
410 .unwrap_or(false)
411 };
412 }
413
414 let mut iter = SubtagIterator::new(other.as_bytes());
415 if !subtag_matches!(subtags::Language, iter, self.id.language) {
416 return false;
417 }
418 if let Some(ref script) = self.id.script
419 && !subtag_matches!(subtags::Script, iter, *script)
420 {
421 return false;
422 }
423 if let Some(ref region) = self.id.region
424 && !subtag_matches!(subtags::Region, iter, *region)
425 {
426 return false;
427 }
428 for variant in self.id.variants.iter() {
429 if !subtag_matches!(subtags::Variant, iter, *variant) {
430 return false;
431 }
432 }
433 if !self.extensions.is_empty() {
434 match extensions::Extensions::try_from_iter(&mut iter) {
435 Ok(exts) => {
436 if self.extensions != exts {
437 return false;
438 }
439 }
440 Err(_) => {
441 return false;
442 }
443 }
444 }
445 iter.next().is_none()
446 }
447
448 #[doc(hidden)] // macro use
449 #[expect(clippy::type_complexity)]
450 pub const fn try_from_utf8_with_single_variant_single_keyword_unicode_extension(
451 code_units: &[u8],
452 ) -> Result<
453 (
454 subtags::Language,
455 Option<subtags::Script>,
456 Option<subtags::Region>,
457 Option<subtags::Variant>,
458 Option<(extensions::unicode::Key, Option<Subtag>)>,
459 ),
460 ParseError,
461 > {
462 parse_locale_with_single_variant_single_keyword_unicode_keyword_extension(
463 code_units,
464 ParserMode::Locale,
465 )
466 }
467
468 pub(crate) fn for_each_subtag_str<E, F>(&self, f: &mut F) -> Result<(), E>
469 where
470 F: FnMut(&str) -> Result<(), E>,
471 {
472 self.id.for_each_subtag_str(f)?;
473 self.extensions.for_each_subtag_str(f)?;
474 Ok(())
475 }
476}
477
478impl AsRef<LanguageIdentifier> for Locale {
479 fn as_ref(&self) -> &LanguageIdentifier {
480 &self.id
481 }
482}
483
484/// ✨ *Enabled with the `alloc` Cargo feature.*
485#[cfg(feature = "alloc")]
486impl FromStr for Locale {
487 type Err = ParseError;
488
489 #[inline]
490 fn from_str(s: &str) -> Result<Self, Self::Err> {
491 Self::try_from_str(s)
492 }
493}
494
495impl From<LanguageIdentifier> for Locale {
496 fn from(id: LanguageIdentifier) -> Self {
497 Self {
498 id,
499 extensions: extensions::Extensions::default(),
500 }
501 }
502}
503
504impl From<Locale> for LanguageIdentifier {
505 fn from(loc: Locale) -> Self {
506 loc.id
507 }
508}
509
510impl core::fmt::Debug for Locale {
511 fn fmt(&self, f: &mut core::fmt::Formatter) -> core::fmt::Result {
512 writeable::Writeable::write_to(self, f)
513 }
514}
515
516impl_writeable_for_each_subtag_str_no_test!(Locale, selff, selff.extensions.is_empty() => selff.id.writeable_borrow());
517
518#[test]
519fn test_writeable() {
520 use writeable::assert_writeable_eq;
521 assert_writeable_eq!(Locale::UNKNOWN, "und");
522 assert_writeable_eq!("und-001".parse::<Locale>().unwrap(), "und-001");
523 assert_writeable_eq!("und-Mymr".parse::<Locale>().unwrap(), "und-Mymr");
524 assert_writeable_eq!("my-Mymr-MM".parse::<Locale>().unwrap(), "my-Mymr-MM");
525 assert_writeable_eq!(
526 "my-Mymr-MM-posix".parse::<Locale>().unwrap(),
527 "my-Mymr-MM-posix",
528 );
529 assert_writeable_eq!(
530 "zh-macos-posix".parse::<Locale>().unwrap(),
531 "zh-macos-posix",
532 );
533 assert_writeable_eq!(
534 "my-t-my-d0-zawgyi".parse::<Locale>().unwrap(),
535 "my-t-my-d0-zawgyi",
536 );
537 assert_writeable_eq!(
538 "ar-SA-u-ca-islamic-civil".parse::<Locale>().unwrap(),
539 "ar-SA-u-ca-islamic-civil",
540 );
541 assert_writeable_eq!(
542 "en-001-x-foo-bar".parse::<Locale>().unwrap(),
543 "en-001-x-foo-bar",
544 );
545 assert_writeable_eq!("und-t-m0-true".parse::<Locale>().unwrap(), "und-t-m0-true",);
546}
547
548/// # Examples
549///
550/// ```
551/// use icu::locale::Locale;
552/// use icu::locale::{locale, subtags::language};
553///
554/// assert_eq!(Locale::from(language!("en")), locale!("en"));
555/// ```
556impl From<subtags::Language> for Locale {
557 fn from(language: subtags::Language) -> Self {
558 Self {
559 id: language.into(),
560 extensions: extensions::Extensions::new(),
561 }
562 }
563}
564
565/// # Examples
566///
567/// ```
568/// use icu::locale::Locale;
569/// use icu::locale::{locale, subtags::script};
570///
571/// assert_eq!(Locale::from(Some(script!("latn"))), locale!("und-Latn"));
572/// ```
573impl From<Option<subtags::Script>> for Locale {
574 fn from(script: Option<subtags::Script>) -> Self {
575 Self {
576 id: script.into(),
577 extensions: extensions::Extensions::new(),
578 }
579 }
580}
581
582/// # Examples
583///
584/// ```
585/// use icu::locale::Locale;
586/// use icu::locale::{locale, subtags::region};
587///
588/// assert_eq!(Locale::from(Some(region!("US"))), locale!("und-US"));
589/// ```
590impl From<Option<subtags::Region>> for Locale {
591 fn from(region: Option<subtags::Region>) -> Self {
592 Self {
593 id: region.into(),
594 extensions: extensions::Extensions::new(),
595 }
596 }
597}
598
599/// # Examples
600///
601/// ```
602/// use icu::locale::Locale;
603/// use icu::locale::{
604/// locale,
605/// subtags::{language, region, script},
606/// };
607///
608/// assert_eq!(
609/// Locale::from((
610/// language!("en"),
611/// Some(script!("Latn")),
612/// Some(region!("US"))
613/// )),
614/// locale!("en-Latn-US")
615/// );
616/// ```
617impl
618 From<(
619 subtags::Language,
620 Option<subtags::Script>,
621 Option<subtags::Region>,
622 )> for Locale
623{
624 fn from(
625 lsr: (
626 subtags::Language,
627 Option<subtags::Script>,
628 Option<subtags::Region>,
629 ),
630 ) -> Self {
631 Self {
632 id: lsr.into(),
633 extensions: extensions::Extensions::new(),
634 }
635 }
636}