/// Locale — language + optional region + directionality. /// /// Locale identifies both the language for translation lookup and the /// region for number/date/currency formatting. /// Text direction. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum TextDirection { LeftToRight, RightToLeft, } /// A locale identifier. #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct Locale { /// BCP 47 language tag (e.g. "en", "ar", "zh-Hant"). pub language: String, /// Optional region (e.g. "US", "GB", "TW"). pub region: Option, } impl Locale { /// Create from a BCP 47 tag like "en-US" or "ar". pub fn new(tag: impl Into) -> Self { let tag = tag.into(); if let Some(idx) = tag.find('-') { let (lang, rest) = tag.split_at(idx); let region = rest.trim_start_matches('-'); Self { language: lang.to_lowercase(), region: if region.is_empty() { None } else { Some(region.to_uppercase()) }, } } else { Self { language: tag.to_lowercase(), region: None, } } } /// The full BCP 47 tag (e.g. "en-US"). pub fn tag(&self) -> String { match &self.region { Some(r) => format!("{}-{}", self.language, r), None => self.language.clone(), } } /// The text direction for this locale. pub fn direction(&self) -> TextDirection { if self.is_rtl() { TextDirection::RightToLeft } else { TextDirection::LeftToRight } } /// Whether this locale uses right-to-left script. pub fn is_rtl(&self) -> bool { // RTL language codes per Unicode CLDR matches!( self.language.as_str(), "ar" // Arabic | "he" | "iw" // Hebrew | "fa" | "per" // Persian/Farsi | "ur" // Urdu | "ps" // Pashto | "ug" // Uyghur | "yi" // Yiddish | "dv" // Maldivian/Dhivehi | "ku" // Kurdish (some scripts) | "sd" // Sindhi ) } /// English (US). pub fn en_us() -> Self { Self::new("en-US") } /// English (GB). pub fn en_gb() -> Self { Self::new("en-GB") } /// Arabic (a common RTL locale). pub fn ar() -> Self { Self::new("ar") } /// Arabic (Saudi Arabia). pub fn ar_sa() -> Self { Self::new("ar-SA") } /// Spanish (Spain). pub fn es_es() -> Self { Self::new("es-ES") } /// French (France). pub fn fr_fr() -> Self { Self::new("fr-FR") } /// Japanese. pub fn ja() -> Self { Self::new("ja") } /// Chinese (Traditional). pub fn zh_hant() -> Self { Self::new("zh-Hant") } } impl std::fmt::Display for Locale { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.tag()) } } #[cfg(test)] mod tests { use super::*; #[test] fn locale_parse_with_region() { let l = Locale::new("en-US"); assert_eq!(l.language, "en"); assert_eq!(l.region, Some("US".to_string())); } #[test] fn locale_parse_without_region() { let l = Locale::new("ja"); assert_eq!(l.language, "ja"); assert_eq!(l.region, None); } #[test] fn locale_tag_roundtrip() { let l = Locale::new("fr-FR"); assert_eq!(l.tag(), "fr-FR"); } #[test] fn arabic_is_rtl() { assert!(Locale::new("ar").is_rtl()); assert!(Locale::new("ar-SA").is_rtl()); } #[test] fn hebrew_is_rtl() { assert!(Locale::new("he").is_rtl()); } #[test] fn persian_is_rtl() { assert!(Locale::new("fa").is_rtl()); } #[test] fn english_is_ltr() { assert!(!Locale::new("en").is_rtl()); assert_eq!(Locale::new("en-US").direction(), TextDirection::LeftToRight); } #[test] fn rtl_direction() { assert_eq!(Locale::ar().direction(), TextDirection::RightToLeft); } #[test] fn locale_display() { assert_eq!(format!("{}", Locale::en_us()), "en-US"); } }