pub(crate) fn canonicalise(tag: &str) -> Option<String> {
let mut subtags = tag.split('-');
let language = subtags.next()?;
if !(2..=3).contains(&language.len()) || !language.bytes().all(|b| b.is_ascii_alphabetic()) {
return None;
}
let mut canonical = language.to_ascii_lowercase();
let mut next = subtags.next();
if let Some(script) = next.filter(|part| is_script(part)) {
let (first, rest) = script.split_at(1);
canonical.push('-');
canonical.push_str(&first.to_ascii_uppercase());
canonical.push_str(&rest.to_ascii_lowercase());
next = subtags.next();
}
if let Some(region) = next.filter(|part| is_region(part)) {
canonical.push('-');
canonical.push_str(®ion.to_ascii_uppercase());
next = subtags.next();
}
next.is_none().then_some(canonical)
}
fn is_script(part: &str) -> bool {
part.len() == 4 && part.bytes().all(|b| b.is_ascii_alphabetic())
}
fn is_region(part: &str) -> bool {
(part.len() == 2 && part.bytes().all(|b| b.is_ascii_alphabetic()))
|| (part.len() == 3 && part.bytes().all(|b| b.is_ascii_digit()))
}
pub(crate) fn locales_of(names: &[String]) -> Result<Vec<Option<String>>, String> {
let stems: Vec<Vec<&str>> = names.iter().map(|name| stem(name)).collect();
let shared = shared_prefix(&stems);
names
.iter()
.zip(&stems)
.map(|(name, segments)| locale_of(name, &segments[shared..]))
.collect()
}
fn locale_of(name: &str, remainder: &[&str]) -> Result<Option<String>, String> {
if remainder.is_empty() {
return Ok(None);
}
let tag = remainder.join("-");
canonicalise(&tag).map(Some).ok_or_else(|| {
format!("{name}: \"{tag}\" is not a language tag, so this is not a catalogue set")
})
}
pub(crate) fn from_prefix(name: &str, prefix: &str) -> Result<Option<String>, String> {
let stem = name
.strip_suffix(".json")
.or_else(|| name.strip_suffix(".arb"))
.unwrap_or(name);
let rest = stem
.strip_prefix(prefix)
.ok_or_else(|| format!("{name} is not a {prefix} catalogue"))?;
if rest.is_empty() {
return Ok(None);
}
let tag = rest.trim_start_matches(['.', '_']);
canonicalise(tag)
.map(Some)
.ok_or_else(|| format!("{name}: \"{tag}\" is not a language tag"))
}
fn stem(name: &str) -> Vec<&str> {
let stem = name
.strip_suffix(".json")
.or_else(|| name.strip_suffix(".arb"))
.unwrap_or(name);
stem.split(['.', '_']).collect()
}
fn shared_prefix(stems: &[Vec<&str>]) -> usize {
let Some((first, rest)) = stems.split_first() else {
return 0;
};
let mut shared = first.len();
for other in rest {
let common = first
.iter()
.zip(other.iter())
.take_while(|(a, b)| a == b)
.count();
shared = shared.min(common);
}
shared
}
#[cfg(test)]
mod tests {
use super::*;
fn locales(names: &[&str]) -> Vec<Option<String>> {
let owned: Vec<String> = names.iter().map(|name| (*name).to_string()).collect();
locales_of(&owned).expect("a locale for every name")
}
#[test]
fn a_language_tag_gets_one_spelling() {
assert_eq!(canonicalise("en").as_deref(), Some("en"));
assert_eq!(canonicalise("EN").as_deref(), Some("en"));
assert_eq!(canonicalise("pt-br").as_deref(), Some("pt-BR"));
assert_eq!(canonicalise("zh-cn").as_deref(), Some("zh-CN"));
assert_eq!(canonicalise("zh-hans").as_deref(), Some("zh-Hans"));
assert_eq!(canonicalise("zh-Hant-HK").as_deref(), Some("zh-Hant-HK"));
assert_eq!(canonicalise("es-419").as_deref(), Some("es-419"));
assert_eq!(canonicalise("fil").as_deref(), Some("fil"));
}
#[test]
fn what_is_not_a_language_tag() {
for text in [
"l10n",
"bundle",
"package",
"tsconfig",
"e",
"",
"en-",
"en-GB-x-private",
] {
assert_eq!(canonicalise(text), None, "{text}");
}
}
#[test]
fn a_name_alone_is_not_enough_to_decide() {
assert_eq!(canonicalise("nls").as_deref(), Some("nls"));
assert_eq!(locales(&["package.nls.json"]), [None]);
assert_eq!(
locales(&["package.nls.json", "package.nls.de.json"]),
[None, Some("de".to_string())]
);
}
#[test]
fn a_directory_of_bare_locales_reads_as_itself() {
assert_eq!(
locales(&["en.json", "pt-BR.json", "zh-CN.json"]),
[
Some("en".to_string()),
Some("pt-BR".to_string()),
Some("zh-CN".to_string())
]
);
}
#[test]
fn a_shared_prefix_is_not_part_of_the_locale() {
assert_eq!(
locales(&[
"bundle.l10n.json",
"bundle.l10n.pt-br.json",
"bundle.l10n.zh-cn.json"
]),
[None, Some("pt-BR".to_string()), Some("zh-CN".to_string())]
);
}
#[test]
fn the_manifest_convention_reads_the_same_way() {
assert_eq!(
locales(&["package.nls.json", "package.nls.zh-cn.json"]),
[None, Some("zh-CN".to_string())]
);
}
#[test]
fn a_single_shared_segment_works_too() {
assert_eq!(
locales(&["messages.en.json", "messages.es.json"]),
[Some("en".to_string()), Some("es".to_string())]
);
}
#[test]
fn an_underscore_separates_like_a_dot_does() {
assert_eq!(
locales(&["app_en.arb", "app_es.arb", "app_pt_BR.arb"]),
[
Some("en".to_string()),
Some("es".to_string()),
Some("pt-BR".to_string())
]
);
assert_eq!(
locales(&["pt_BR.json", "zh_CN.json"]),
[Some("pt-BR".to_string()), Some("zh-CN".to_string())]
);
}
#[test]
fn a_leftover_that_is_not_a_tag_is_refused_by_name() {
let names = ["package.json".to_string(), "tsconfig.json".to_string()];
let error = locales_of(&names).expect_err("a refusal");
assert!(error.contains("package.json"), "{error}");
assert!(error.contains("not a language tag"), "{error}");
}
#[test]
fn a_leftover_of_several_segments_is_refused_rather_than_joined() {
let names = ["en.json".to_string(), "fr.CA.extra.json".to_string()];
assert!(locales_of(&names).is_err());
}
#[test]
fn a_lone_file_is_the_base_catalogue() {
assert_eq!(locales(&["bundle.l10n.json"]), [None]);
assert_eq!(locales(&["en.json"]), [None]);
}
#[test]
fn no_files_is_no_locales() {
assert_eq!(locales_of(&[]).expect("nothing to refuse"), []);
}
#[test]
fn a_known_prefix_reads_one_name_at_a_time() {
assert_eq!(
from_prefix("package.nls.json", "package.nls").expect("a locale"),
None
);
assert_eq!(
from_prefix("package.nls.zh-cn.json", "package.nls").expect("a locale"),
Some("zh-CN".to_string())
);
assert_eq!(
from_prefix("bundle.l10n.pt-br.json", "bundle.l10n").expect("a locale"),
Some("pt-BR".to_string())
);
}
#[test]
fn a_known_prefix_still_refuses_a_leftover_that_is_not_a_tag() {
assert!(from_prefix("package.nls.backup.json", "package.nls").is_err());
assert!(from_prefix("something.else.json", "package.nls").is_err());
}
}