use std::borrow::Cow;
use std::collections::BTreeMap;
use std::fmt::Write as _;
use crate::error::Error;
pub const LOCALE_FIELDS: &[&str] = &["default", "native", "min-grouping"];
pub const SYSTEM_FIELDS: &[&str] = &[
"decimal",
"group",
"minus",
"plus",
"percent",
"decimal-pattern",
"percent-pattern",
"currency",
"currency-alpha",
"currency-none",
"accounting",
"accounting-alpha",
"accounting-none",
"currency-name-zero",
"currency-name-one",
"currency-name-two",
"currency-name-few",
"currency-name-many",
"currency-name-other",
];
pub const ROOT: &str = "und";
fn escaped(c: char) -> bool {
c == '\\'
|| c == '='
|| c.is_whitespace()
|| c.is_control()
|| matches!(c,
'\u{ad}' | '\u{600}'..='\u{605}' | '\u{61c}' | '\u{6dd}' | '\u{70f}' | '\u{180e}'
| '\u{200b}'..='\u{200f}' | '\u{202a}'..='\u{202e}' | '\u{2060}'..='\u{2064}'
| '\u{2066}'..='\u{206f}' | '\u{feff}' | '\u{fff9}'..='\u{fffb}')
}
pub fn escape(s: &str) -> String {
escape_with(s, false)
}
pub fn escape_tab(s: &str) -> String {
escape_with(s, true)
}
fn escape_with(s: &str, keep_space: bool) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
if escaped(c) && !(keep_space && c == ' ') {
let _ = write!(out, "\\u{{{:x}}}", u32::from(c));
} else {
out.push(c);
}
}
out
}
pub fn unescape(s: &str) -> Option<Cow<'_, str>> {
if !s.contains('\\') {
return Some(Cow::Borrowed(s));
}
let mut out = String::with_capacity(s.len());
let mut rest = s;
while let Some(at) = rest.find('\\') {
out.push_str(&rest[..at]);
let tail = rest[at..].strip_prefix("\\u{")?;
let end = tail.find('}')?;
let c = char::from_u32(u32::from_str_radix(&tail[..end], 16).ok()?)?;
out.push(c);
rest = &tail[end + 1..];
}
out.push_str(rest);
Some(Cow::Owned(out))
}
pub type Fields<'t> = BTreeMap<&'t str, Cow<'t, str>>;
#[derive(Default, Debug)]
pub struct Table<'t> {
pub cldr: &'t str,
pub digits: BTreeMap<&'t str, Cow<'t, str>>,
pub likely: BTreeMap<&'t str, &'t str>,
pub parents: BTreeMap<&'t str, &'t str>,
pub locales: BTreeMap<&'t str, Fields<'t>>,
pub systems: BTreeMap<&'t str, BTreeMap<&'t str, Fields<'t>>>,
}
fn fields<'t>(
words: impl Iterator<Item = &'t str>,
known: &[&str],
line: usize,
) -> Result<Fields<'t>, Error> {
let mut out = Fields::new();
for w in words {
let (k, v) = w.split_once('=').ok_or_else(|| Error::Table {
line,
message: format!("expected key=value, found {w:?}"),
})?;
if !known.contains(&k) {
return Err(Error::Table {
line,
message: format!("unknown field {k:?}"),
});
}
let v = unescape(v).ok_or_else(|| Error::Table {
line,
message: format!("bad escape in {w:?}"),
})?;
if out.insert(k, v).is_some() {
return Err(Error::Table {
line,
message: format!("field {k:?} twice"),
});
}
}
Ok(out)
}
impl<'t> Table<'t> {
pub fn parse(text: &'t str) -> Result<Table<'t>, Error> {
let mut t = Table::default();
for (i, line) in text.lines().enumerate() {
let n = i + 1;
if line.is_empty() || line.starts_with('#') {
continue;
}
let bad = |message: &str| Error::Table {
line: n,
message: message.to_owned(),
};
let mut words = line.split(' ');
let kind = words.next().unwrap_or("");
match kind {
"cldr" => t.cldr = words.next().ok_or_else(|| bad("cldr version"))?,
"digits" => {
let (Some(nu), Some(d)) = (words.next(), words.next()) else {
return Err(bad("expected `digits nu digits`"));
};
let d = unescape(d).ok_or_else(|| bad("bad escape"))?;
if d.chars().count() != 10 {
return Err(bad("not ten digits"));
}
t.digits.insert(nu, d);
}
"likely" | "parent" => {
let (Some(a), Some(b)) = (words.next(), words.next()) else {
return Err(bad("expected two words"));
};
let map = if kind == "likely" {
&mut t.likely
} else {
&mut t.parents
};
map.insert(a, b);
}
"locale" => {
let tag = words.next().ok_or_else(|| bad("locale tag"))?;
let f = fields(words, LOCALE_FIELDS, n)?;
if t.locales.insert(tag, f).is_some() {
return Err(bad("locale twice"));
}
}
"system" => {
let (Some(tag), Some(nu)) = (words.next(), words.next()) else {
return Err(bad("expected `system tag nu …`"));
};
if !t.locales.contains_key(tag) {
return Err(bad("system line before its locale line"));
}
let f = fields(words, SYSTEM_FIELDS, n)?;
if t.systems.entry(tag).or_default().insert(nu, f).is_some() {
return Err(bad("system twice"));
}
}
_ => return Err(bad("unknown line kind")),
}
}
if !t.locales.contains_key(ROOT) {
return Err(Error::Table {
line: 0,
message: "no root (`und`) locale".to_owned(),
});
}
Ok(t)
}
pub fn parent(&self, locale: &str) -> Option<String> {
if locale == ROOT {
return None;
}
if let Some(p) = self.parents.get(locale) {
return Some((*p).to_owned());
}
let parts: Vec<&str> = locale.split('-').collect();
match parts.as_slice() {
[_] => Some(ROOT.to_owned()),
[lang, script] if script.len() == 4 => {
if self.likely.get(lang) == Some(script) {
Some((*lang).to_owned())
} else {
Some(ROOT.to_owned())
}
}
_ => locale.rsplit_once('-').map(|(p, _)| p.to_owned()),
}
}
pub fn chain(&self, locale: &str) -> Vec<&'t str> {
let mut out = Vec::new();
let mut cur = Some(locale.to_owned());
while let Some(c) = cur {
if let Some((k, _)) = self.locales.get_key_value(c.as_str()) {
out.push(*k);
}
cur = self.parent(&c);
}
out
}
pub fn lookup(&self, base: &str) -> &'t str {
self.chain(base).first().copied().unwrap_or(ROOT)
}
pub fn locale_field(&self, locale: &str, field: &str) -> Option<&str> {
self.chain(locale)
.into_iter()
.find_map(|l| self.locales.get(l)?.get(field).map(AsRef::as_ref))
}
pub fn systems_of(&self, locale: &str) -> Vec<&str> {
let mut out = vec!["latn"];
for f in ["default", "native"] {
if let Some(nu) = self.locale_field(locale, f)
&& !out.contains(&nu)
{
out.push(nu);
}
}
out.sort_unstable();
out
}
pub fn has_system(&self, locale: &str, nu: &str) -> bool {
nu == "latn"
|| ["default", "native"]
.into_iter()
.any(|f| self.locale_field(locale, f) == Some(nu))
}
pub fn system_field(&self, locale: &str, nu: &str, field: &str) -> Option<&str> {
for l in self.chain(locale) {
if !self.has_system(l, nu) {
return None;
}
if let Some(v) = self
.systems
.get(l)
.and_then(|m| m.get(nu))
.and_then(|f| f.get(field))
{
return Some(v);
}
}
None
}
pub fn resolved(&self, locale: &str) -> Record {
let mut r = Record::default();
for f in LOCALE_FIELDS {
if let Some(v) = self.locale_field(locale, f) {
r.fields.insert((*f).to_owned(), v.to_owned());
}
}
for nu in self.systems_of(locale) {
let mut s = BTreeMap::new();
for f in SYSTEM_FIELDS {
if let Some(v) = self.system_field(locale, nu, f) {
s.insert((*f).to_owned(), v.to_owned());
}
}
r.systems.insert(nu.to_owned(), s);
}
r
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Record {
pub fields: BTreeMap<String, String>,
pub systems: BTreeMap<String, BTreeMap<String, String>>,
}
#[cfg(test)]
mod tests {
use super::{Table, escape, unescape};
#[test]
fn escapes_round_trip() {
for s in [
"¤\u{a0}#,##0.00",
"\u{200f}-",
"a\\b",
"x=y",
"٫",
"\u{202f}%",
] {
let e = escape(s);
assert!(!e.contains(' '));
assert_eq!(unescape(&e).as_deref(), Some(s));
}
assert_eq!(escape("\u{a0}"), "\\u{a0}");
assert!(unescape("\\u{zz}").is_none());
}
#[test]
fn parents_and_lookup() {
let text = "cldr 48.2.1\nlikely en Latn\nlikely sr Cyrl\nlikely sr-ME Latn\n\
parent en-AU en-001\nparent sr-Latn und\n\
locale und default=latn native=latn min-grouping=1\n\
system und latn decimal=.\nlocale en\nlocale en-001\nlocale en-AU\n\
locale sr\nlocale sr-Latn\n";
let t = Table::parse(text).expect("parses");
assert_eq!(t.chain("en-AU"), ["en-AU", "en-001", "en", "und"]);
assert_eq!(t.lookup("en-Latn-US"), "en");
assert_eq!(t.lookup("en-Cyrl"), "und");
assert_eq!(t.lookup("sr-Latn-RS"), "sr-Latn");
assert_eq!(t.chain("sr-Latn"), ["sr-Latn", "und"]);
assert_eq!(t.system_field("en-AU", "latn", "decimal"), Some("."));
assert_eq!(t.system_field("en-AU", "arab", "decimal"), None);
}
}