git.lucas.co / cce-core
GUI-free half of the cce toolkit: config, input, IPC, spec parsers
git clone https://git.lucas.co/cce-core.git

src/locale.rs (5K)

  1 //! The user's locale, as a BCP 47 tag (`ja-JP`).
  2 //!
  3 //! What the toolkit's font systems are built with: cosmic-text orders its font
  4 //! fallback by it, so the Han characters Chinese and Japanese share are drawn from
  5 //! the reader's own variant. It was the literal `"en-US"` in every constructor
  6 //! until 2026-10-08 (`docs/rfc-accessibility-locale.md`, phase 0).
  7 //!
  8 //! Natively it comes from the environment, the way glibc's `setlocale` reads it
  9 //! for text: `LC_ALL`, else `LC_CTYPE`, else `LANG`, the first one set and not
 10 //! empty. A page has no environment, so the browser shell hands over
 11 //! `navigator.language` through [`set_locale`] before anything shapes text.
 12 //! `C`, `POSIX` and anything that is not a locale name read as `en-US`.
 13 
 14 use std::sync::OnceLock;
 15 
 16 /// What every lookup answers when nothing better is known.
 17 pub const DEFAULT: &str = "en-US";
 18 
 19 static LOCALE: OnceLock<String> = OnceLock::new();
 20 
 21 /// The user's locale as a BCP 47 tag, decided once per process.
 22 pub fn locale() -> &'static str {
 23     LOCALE.get_or_init(|| from_env(|k| std::env::var(k).ok()).unwrap_or_else(|| DEFAULT.to_string()))
 24 }
 25 
 26 /// Decide the locale for this process, ahead of the first [`locale`] call: the
 27 /// browser shell's `navigator.language`. Taken as a BCP 47 tag or a POSIX name.
 28 /// A later call, or one after [`locale`] has answered, changes nothing — every
 29 /// font system in the process must agree — and says so by returning false.
 30 pub fn set_locale(tag: &str) -> bool {
 31     let tag = from_posix(tag).unwrap_or_else(|| DEFAULT.to_string());
 32     LOCALE.set(tag).is_ok()
 33 }
 34 
 35 /// The locale the environment names, through `get`: `LC_ALL`, `LC_CTYPE`,
 36 /// `LANG`, the first set and non-empty. `None` when none is, or it is `C` /
 37 /// `POSIX` or unreadable.
 38 pub fn from_env(get: impl Fn(&str) -> Option<String>) -> Option<String> {
 39     ["LC_ALL", "LC_CTYPE", "LANG"]
 40         .iter()
 41         .filter_map(|k| get(k))
 42         .find(|v| !v.is_empty())
 43         .and_then(|v| from_posix(&v))
 44 }
 45 
 46 /// A POSIX locale name (`ja_JP.UTF-8`, `sr_RS@latin`, `de`) or a BCP 47 tag
 47 /// (`pt-BR`) as a BCP 47 tag: the codeset and modifier dropped, `_` made `-`,
 48 /// the language lower-case and a two-letter region upper-case. `None` for `C`,
 49 /// `POSIX` and anything whose language is not two or three letters.
 50 pub fn from_posix(name: &str) -> Option<String> {
 51     let base = name.trim().split(['.', '@']).next().unwrap_or("");
 52     let mut parts = base.split(['_', '-']).filter(|p| !p.is_empty());
 53     let lang = parts.next()?;
 54     if !(2..=3).contains(&lang.len()) || !lang.chars().all(|c| c.is_ascii_alphabetic()) {
 55         return None;
 56     }
 57     let mut tag = lang.to_ascii_lowercase();
 58     for p in parts {
 59         if !p.chars().all(|c| c.is_ascii_alphanumeric()) {
 60             return None;
 61         }
 62         tag.push('-');
 63         if p.len() == 2 && p.chars().all(|c| c.is_ascii_alphabetic()) {
 64             tag.push_str(&p.to_ascii_uppercase());
 65         } else {
 66             tag.push_str(p);
 67         }
 68     }
 69     Some(tag)
 70 }
 71 
 72 #[cfg(test)]
 73 mod tests {
 74     use super::*;
 75 
 76     #[test]
 77     fn posix_names_become_bcp47_tags() {
 78         assert_eq!(from_posix("ja_JP.UTF-8").as_deref(), Some("ja-JP"));
 79         assert_eq!(from_posix("en_US.UTF-8").as_deref(), Some("en-US"));
 80         assert_eq!(from_posix("de_DE@euro").as_deref(), Some("de-DE"));
 81         assert_eq!(from_posix("sr_RS@latin").as_deref(), Some("sr-RS"));
 82         assert_eq!(from_posix("fr").as_deref(), Some("fr"));
 83         assert_eq!(from_posix("pt-BR").as_deref(), Some("pt-BR"), "already a tag");
 84         assert_eq!(from_posix("zh-Hant-TW").as_deref(), Some("zh-Hant-TW"), "a script subtag kept");
 85         assert_eq!(from_posix("ast_ES.UTF-8").as_deref(), Some("ast-ES"), "three-letter language");
 86     }
 87 
 88     #[test]
 89     fn c_posix_and_junk_are_not_locales() {
 90         assert_eq!(from_posix("C"), None);
 91         assert_eq!(from_posix("C.UTF-8"), None);
 92         assert_eq!(from_posix("POSIX"), None);
 93         assert_eq!(from_posix(""), None);
 94         assert_eq!(from_posix("en_U$"), None);
 95     }
 96 
 97     #[test]
 98     fn the_environment_is_read_as_setlocale_reads_it() {
 99         let env = |pairs: &'static [(&'static str, &'static str)]| {
100             move |k: &str| pairs.iter().find(|(n, _)| *n == k).map(|(_, v)| v.to_string())
101         };
102         assert_eq!(from_env(env(&[("LANG", "de_DE.UTF-8")])).as_deref(), Some("de-DE"));
103         assert_eq!(
104             from_env(env(&[("LANG", "de_DE.UTF-8"), ("LC_CTYPE", "ja_JP.UTF-8")])).as_deref(),
105             Some("ja-JP"),
106             "LC_CTYPE before LANG"
107         );
108         assert_eq!(
109             from_env(env(&[("LC_ALL", "fr_FR.UTF-8"), ("LC_CTYPE", "ja_JP.UTF-8")])).as_deref(),
110             Some("fr-FR"),
111             "LC_ALL before everything"
112         );
113         assert_eq!(
114             from_env(env(&[("LC_ALL", ""), ("LANG", "it_IT.UTF-8")])).as_deref(),
115             Some("it-IT"),
116             "an empty variable is unset"
117         );
118         assert_eq!(from_env(env(&[("LANG", "C.UTF-8")])), None, "C is no locale");
119         assert_eq!(from_env(env(&[])), None);
120     }
121 }