///|
/// A normalized, practical subset of a BCP 47 language tag.
///
/// MoonLocale keeps the language, optional script, optional region, and any
/// remaining variants separately. This is sufficient for application locale
/// negotiation without coupling callers to a large standards database.
pub struct Locale {
language : String
script : String?
region : String?
variants : Array[String]
} derive(Eq, Debug)
///|
/// Reports why a locale tag could not be parsed.
pub enum LocaleError {
EmptyTag
EmptySubtag(Int)
InvalidLanguage(String)
InvalidVariant(String)
DuplicateScript(String)
DuplicateRegion(String)
} derive(Eq, Debug)
///|
pub fn LocaleError::message(self : LocaleError) -> String {
match self {
EmptyTag => "locale tag must not be empty"
EmptySubtag(index) => "locale subtag \{index} must not be empty"
InvalidLanguage(value) => "invalid language subtag: \{value}"
InvalidVariant(value) => "invalid variant subtag: \{value}"
DuplicateScript(value) => "duplicate script subtag: \{value}"
DuplicateRegion(value) => "duplicate region subtag: \{value}"
}
}
///|
fn ascii_is_alpha(value : Char) -> Bool {
value.is_ascii_alphabetic()
}
///|
fn ascii_is_digit(value : Char) -> Bool {
value.is_ascii_digit()
}
///|
fn ascii_is_alnum(value : Char) -> Bool {
ascii_is_alpha(value) || ascii_is_digit(value)
}
///|
fn ascii_all(value : String, predicate : (Char) -> Bool) -> Bool {
if value.length() == 0 {
return false
}
for character in value {
if !predicate(character) {
return false
}
}
true
}
///|
fn ascii_lower(value : String) -> String {
let output = StringBuilder::new()
for character in value {
output.write_char(character.to_ascii_lowercase())
}
output.to_string()
}
///|
fn ascii_upper(value : String) -> String {
let output = StringBuilder::new()
for character in value {
output.write_char(character.to_ascii_uppercase())
}
output.to_string()
}
///|
fn ascii_title(value : String) -> String {
let output = StringBuilder::new()
for index, character in ascii_lower(value) {
if index == 0 {
output.write_char(character.to_ascii_uppercase())
} else {
output.write_char(character)
}
}
output.to_string()
}
///|
fn is_language(value : String) -> Bool {
let length = value.length()
length >= 2 && length <= 8 && ascii_all(value, ascii_is_alpha)
}
///|
fn is_script(value : String) -> Bool {
value.length() == 4 && ascii_all(value, ascii_is_alpha)
}
///|
fn is_region(value : String) -> Bool {
(value.length() == 2 && ascii_all(value, ascii_is_alpha)) ||
(value.length() == 3 && ascii_all(value, ascii_is_digit))
}
///|
fn is_variant(value : String) -> Bool {
let length = value.length()
(length >= 5 && length <= 8 && ascii_all(value, ascii_is_alnum)) ||
(
length == 4 &&
value[0] >= 48 &&
value[0] <= 57 &&
ascii_all(value, ascii_is_alnum)
)
}
///|
/// Parses a locale tag such as `en`, `pt-BR`, or `zh-Hans-CN`.
///
/// Underscores are accepted as a convenience for operating-system locale
/// values. The returned locale always renders with hyphens and conventional
/// casing.
pub fn Locale::parse(tag : String) -> Result[Locale, LocaleError] {
if tag.length() == 0 {
return Err(EmptyTag)
}
let normalized = tag.replace_all(old="_", new="-")
let parts = normalized.split("-").map(fn(part) { "\{part}" }).to_array()
for index, part in parts {
if part.length() == 0 {
return Err(EmptySubtag(index))
}
}
let language = parts[0]
if !is_language(language) {
return Err(InvalidLanguage(language))
}
let mut script : String? = None
let mut region : String? = None
let variants : Array[String] = []
for index = 1; index < parts.length(); index = index + 1 {
let part = parts[index]
if is_script(part) {
match script {
None => script = Some(ascii_title(part))
Some(_) => return Err(DuplicateScript(part))
}
} else if is_region(part) {
match region {
None => region = Some(ascii_upper(part))
Some(_) => return Err(DuplicateRegion(part))
}
} else if is_variant(part) {
variants.push(ascii_lower(part))
} else {
return Err(InvalidVariant(part))
}
}
Ok({ language: ascii_lower(language), script, region, variants })
}
///|
/// Creates a locale and returns `None` for an invalid tag.
pub fn Locale::from_string(tag : String) -> Locale? {
match Locale::parse(tag) {
Ok(locale) => Some(locale)
Err(_) => None
}
}
///|
pub fn Locale::language(self : Locale) -> String {
self.language
}
///|
pub fn Locale::script(self : Locale) -> String? {
self.script
}
///|
pub fn Locale::region(self : Locale) -> String? {
self.region
}
///|
pub fn Locale::variants(self : Locale) -> Array[String] {
self.variants.copy()
}
///|
pub fn Locale::tag(self : Locale) -> String {
let output = StringBuilder::new()
output.write_string(self.language)
match self.script {
Some(value) => {
output.write_char('-')
output.write_string(value)
}
None => ()
}
match self.region {
Some(value) => {
output.write_char('-')
output.write_string(value)
}
None => ()
}
for variant in self.variants {
output.write_char('-')
output.write_string(variant)
}
output.to_string()
}
///|
/// Returns the locale with all variants removed.
pub fn Locale::without_variants(self : Locale) -> Locale {
{ ..self, variants: [] }
}
///|
/// Returns the locale with its region removed.
pub fn Locale::without_region(self : Locale) -> Locale {
{ ..self, region: None, variants: [] }
}
///|
/// Returns only the language component.
pub fn Locale::language_only(self : Locale) -> Locale {
{ language: self.language, script: None, region: None, variants: [] }
}
///|
/// Produces a most-specific-to-least-specific fallback chain.
///
/// For `zh-Hans-CN-posix`, this returns `zh-Hans-CN-posix`,
/// `zh-Hans-CN`, `zh-Hans`, and `zh`.
pub fn Locale::fallback_chain(self : Locale) -> Array[Locale] {
let result : Array[Locale] = [self]
if !self.variants.is_empty() {
result.push(self.without_variants())
}
match self.region {
Some(_) => result.push(self.without_region())
None => ()
}
match self.script {
Some(_) => result.push(self.language_only())
None =>
if result[result.length() - 1].tag() != self.language {
result.push(self.language_only())
}
}
result
}
///|
/// Measures how closely two locales match.
///
/// A negative score means the languages differ. Higher scores prefer exact
/// tags, then matching scripts, regions, and variants.
pub fn Locale::match_score(self : Locale, candidate : Locale) -> Int {
if self.language != candidate.language {
return -1
}
let mut score = 100
match (self.script, candidate.script) {
(Some(left), Some(right)) =>
if left == right {
score += 30
} else {
return -1
}
(None, None) => score += 10
_ => ()
}
match (self.region, candidate.region) {
(Some(left), Some(right)) => if left == right { score += 20 }
(None, None) => score += 5
_ => ()
}
if self.variants == candidate.variants {
score += 10
}
if self.tag() == candidate.tag() {
score += 100
}
score
}
///|
/// Selects the best supported locale for a requested locale.
pub fn best_locale(
requested : Locale,
supported : Array[Locale],
default? : Locale? = None,
) -> Locale? {
let mut best : Locale? = None
let mut best_score = -1
for candidate in supported {
let score = requested.match_score(candidate)
if score > best_score {
best = Some(candidate)
best_score = score
}
}
match best {
Some(_) => best
None => default
}
}
///|
/// Parses a list of supported tags and ignores invalid entries.
pub fn valid_locales(tags : Array[String]) -> Array[Locale] {
let result : Array[Locale] = []
for tag in tags {
match Locale::from_string(tag) {
Some(locale) => result.push(locale)
None => ()
}
}
result
}