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 }