use std::collections::BTreeMap;
use rustc_hash::FxHashMap;
use crate::content::Id;
use crate::locks::Relock;
use crate::{Host, Load, Off};
pub(crate) mod reason;
#[must_use]
pub fn language() -> Language {
crate::host().map_or(Language::FALLBACK, |host| host.language())
}
impl Host {
#[must_use]
pub fn language(&self) -> Language {
let language = Language((self.api().language)(self.handle()));
if language.code().is_empty() {
Language::FALLBACK
} else {
language
}
}
}
#[must_use]
pub fn game_text(id: u32) -> Option<&'static str> {
game_text_in(id, language())
}
#[must_use]
pub fn game_text_in(id: u32, language: Language) -> Option<&'static str> {
crate::host()?.game_text(id, language)
}
impl Host {
#[must_use]
pub fn game_text(&self, id: u32, language: Language) -> Option<&'static str> {
let api = self.api();
let mut out = crate::abi::Str::EMPTY;
if !(api.game_text)(self.handle(), id, language.0, &raw mut out).is_ok() {
return None;
}
unsafe { out.as_str() }
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct Language(pub u32);
macro_rules! languages {
($($name:ident = $id:literal, $code:literal, $parent:ident, $doc:literal;)*) => {
impl Language {
$(#[doc = $doc] pub const $name: Self = Self($id);)*
pub const ALL: &[Self] = &[$(Self::$name),*];
const fn parent(self) -> Self {
match self.0 {
$($id => Self::$parent,)*
_ => Self::FALLBACK,
}
}
#[must_use]
pub const fn code(self) -> &'static str {
match self.0 {
$($id => $code,)*
_ => "",
}
}
#[must_use]
pub fn from_code(code: &str) -> Option<Self> {
let code = code.to_ascii_lowercase();
match code.as_str() {
$($code => Some(Self::$name),)*
_ => None,
}
}
}
};
}
languages! {
FALLBACK = 0, "en", FALLBACK, "English, and every language without a text of its own.";
EN_US = 1, "en-us", FALLBACK, "English (United States).";
EN_GB = 2, "en-gb", FALLBACK, "English (United Kingdom).";
DE_DE = 3, "de", FALLBACK, "German.";
NL_NL = 4, "nl", FALLBACK, "Dutch.";
FR_FR = 5, "fr", FALLBACK, "French.";
IT_IT = 6, "it", FALLBACK, "Italian.";
ES_ES = 7, "es", FALLBACK, "Spanish (Spain).";
ES_MX = 8, "es-mx", ES_419, "Spanish (Mexico).";
RU_RU = 9, "ru", FALLBACK, "Russian.";
PL_PL = 10, "pl", FALLBACK, "Polish.";
JA_JP = 11, "ja", FALLBACK, "Japanese.";
ID_ID = 12, "id", FALLBACK, "Indonesian.";
KO_KR = 13, "ko", FALLBACK, "Korean.";
MS_MY = 14, "ms", FALLBACK, "Malay.";
PT_PT = 15, "pt", FALLBACK, "Portuguese (Portugal).";
PT_BR = 16, "pt-br", PT_PT, "Portuguese (Brazil).";
TH_TH = 17, "th", FALLBACK, "Thai.";
TR_TR = 18, "tr", FALLBACK, "Turkish.";
VI_VN = 19, "vi", FALLBACK, "Vietnamese.";
ZH_CN = 20, "zh-cn", FALLBACK, "Chinese (Simplified).";
ZH_TW = 21, "zh-tw", FALLBACK, "Chinese (Traditional).";
FR_CA = 22, "fr-ca", FR_FR, "French (Canada).";
ES_419 = 23, "es-419", ES_ES, "Spanish (Latin America).";
BG_BG = 24, "bg", FALLBACK, "Bulgarian.";
CS_CZ = 25, "cs", FALLBACK, "Czech.";
DA_DK = 26, "da", FALLBACK, "Danish.";
FI_FI = 27, "fi", FALLBACK, "Finnish.";
EL_GR = 28, "el", FALLBACK, "Greek.";
HU_HU = 29, "hu", FALLBACK, "Hungarian.";
NO_NO = 30, "no", FALLBACK, "Norwegian.";
NB_NO = 31, "nb", NO_NO, "Norwegian (Bokmål).";
RO_RO = 32, "ro", FALLBACK, "Romanian.";
SV_SE = 33, "sv", FALLBACK, "Swedish.";
UK_UA = 34, "uk", FALLBACK, "Ukrainian.";
}
impl Language {
pub const OFFERED: &[Self] = &[
Self::FALLBACK,
Self::DE_DE,
Self::FR_FR,
Self::IT_IT,
Self::ES_ES,
Self::RU_RU,
Self::PL_PL,
Self::JA_JP,
Self::KO_KR,
Self::PT_BR,
Self::TH_TH,
Self::TR_TR,
Self::ZH_CN,
Self::ZH_TW,
Self::UK_UA,
];
pub fn lookup_order(self) -> impl Iterator<Item = Self> {
core::iter::successors(Some(self), |language| {
(*language != Self::FALLBACK).then(|| language.parent())
})
}
}
pub const MOD_NAME: &str = "mod.name";
pub const MOD_DESCRIPTION: &str = "mod.description";
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Texts {
by_language: FxHashMap<Language, BTreeMap<String, String>>,
}
impl Texts {
pub fn parse(toml: &str) -> Result<Self, Off> {
let tables: BTreeMap<String, BTreeMap<String, String>> =
toml::from_str(toml).map_err(|e| Off::new(format!("texts: {}", e.message())))?;
let mut texts = Self::default();
for (code, entries) in tables {
let language = Language::from_code(&code)
.ok_or_else(|| Off::new(format!("texts: unknown language {code:?}")))?;
texts
.by_language
.entry(language)
.or_default()
.extend(entries);
}
Ok(texts)
}
pub fn merge(&mut self, other: Self) {
for (language, entries) in other.by_language {
self.by_language
.entry(language)
.or_default()
.extend(entries);
}
}
#[must_use]
pub fn get(&self, key: &str, language: Language) -> Option<&str> {
language.lookup_order().find_map(|language| {
self.by_language
.get(&language)
.and_then(|entries| entries.get(key))
.map(String::as_str)
})
}
#[must_use]
pub fn fill(&self, key: &str, language: Language, args: &[(&str, &str)]) -> String {
let text = self.get(key, language).unwrap_or(key);
let mut out = String::with_capacity(text.len());
let mut rest = text;
while let Some(open) = rest.find('{') {
out.push_str(&rest[..open]);
let after = &rest[open + 1..];
let value = after.find('}').and_then(|close| {
let name = &after[..close];
args.iter()
.find(|(arg, _)| *arg == name)
.map(|(_, value)| (*value, close))
});
if let Some((value, close)) = value {
out.push_str(value);
rest = &after[close + 1..];
} else {
out.push('{');
rest = after;
}
}
out.push_str(rest);
out
}
#[doc(hidden)]
#[must_use]
pub fn reason(&self, reason: &str, language: Language) -> Option<String> {
let (key, args) = reason::decode(reason);
self.get(key, language)?;
Some(self.fill(key, language, &args))
}
#[must_use]
pub fn to_toml(&self) -> String {
let tables: BTreeMap<&str, &BTreeMap<String, String>> = self
.by_language
.iter()
.map(|(language, entries)| (language.code(), entries))
.collect();
toml::to_string(&tables).unwrap_or_default()
}
pub fn translations<'a>(
&'a self,
key: &'a str,
) -> impl Iterator<Item = (Language, &'a str)> + 'a {
self.by_language
.iter()
.filter_map(move |(&language, entries)| {
entries.get(key).map(|t| (language, t.as_str()))
})
}
pub fn languages(&self) -> impl Iterator<Item = Language> + '_ {
Language::ALL
.iter()
.copied()
.filter(|language| self.by_language.contains_key(language))
}
#[must_use]
pub fn missing_in(&self, languages: &[Language]) -> Vec<(Language, &str)> {
let keys: std::collections::BTreeSet<&str> = self
.by_language
.values()
.flat_map(|entries| entries.keys().map(String::as_str))
.collect();
let has = |language: Language, key: &str| {
language
.lookup_order()
.take_while(|l| *l != Language::FALLBACK || language == Language::FALLBACK)
.any(|l| {
self.by_language
.get(&l)
.is_some_and(|e| e.contains_key(key))
})
};
languages
.iter()
.flat_map(|&language| {
keys.iter()
.filter(move |key| !has(language, key))
.map(move |&key| (language, key))
})
.collect()
}
#[must_use]
pub fn untranslated(&self) -> Vec<(Language, &str)> {
let keys: std::collections::BTreeSet<&str> = self
.by_language
.values()
.flat_map(|entries| entries.keys().map(String::as_str))
.collect();
self.languages()
.flat_map(|language| {
let entries = &self.by_language[&language];
keys.iter()
.filter(|key| !entries.contains_key(**key))
.map(move |&key| (language, key))
})
.collect()
}
}
static HANDED: crate::fresh::Fresh<std::sync::Mutex<Vec<(crate::abi::ModHandle, Texts)>>> =
crate::fresh::Fresh::new();
impl Load<'_> {
pub fn texts(&mut self, embedded: &str) -> Result<Texts, Off> {
let mut texts = Texts::parse(embedded)?;
let data = self.data_dir()?;
let overrides = data
.file_name()
.zip(data.parent().and_then(std::path::Path::parent))
.map(|(id, game)| {
game.join("mod-configs")
.join(format!("{}.texts.toml", id.to_string_lossy()))
});
if let Some(path) = overrides {
match std::fs::read_to_string(&path) {
Ok(text) => texts.merge(
Texts::parse(&text)
.map_err(|off| Off::new(format!("{}: {off}", path.display())))?,
),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => return Err(Off::new(format!("{}: {error}", path.display()))),
}
}
let api = self.host().api();
let me = self.host().handle();
let mut handed = HANDED.get_or_init(std::sync::Mutex::default).relock();
let all = if let Some((_, kept)) = handed.iter_mut().find(|(owner, _)| *owner == me) {
kept.merge(texts.clone());
kept.to_toml()
} else {
handed.push((me, texts.clone()));
texts.to_toml()
};
let _kept = (api.texts)(me, crate::abi::Str::new(&all));
Ok(texts)
}
pub fn add_texts(&mut self, texts: &Texts, tags: &[(&str, Id)]) -> Result<(), Off> {
for &(key, tag) in tags {
let tag = self.declare(tag)?;
let mut any = false;
for (language, text) in texts.translations(key) {
self.add_text(tag, language, text)?;
any = true;
}
if !any {
return Err(Off::new(format!("texts: no text for {key:?}")));
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_text_reason_fills_in_the_language_asked_and_plain_words_name_no_text() {
let texts = Texts::parse(
"[en]\n\"glow.off\" = \"No glow on {build}.\"\n[uk]\n\"glow.off\" = \"Без сяйва на {build}.\"\n",
)
.unwrap();
let sent = reason::encode("glow.off", &[("build", "426")]);
assert_eq!(
texts.reason(&sent, Language::FALLBACK).as_deref(),
Some("No glow on 426.")
);
assert_eq!(
texts.reason(&sent, Language::UK_UA).as_deref(),
Some("Без сяйва на 426.")
);
assert_eq!(texts.reason("glow is missing", Language::UK_UA), None);
}
#[test]
fn every_language_round_trips_through_its_code() {
for &language in Language::ALL {
assert_eq!(Language::from_code(language.code()), Some(language));
}
assert_eq!(Language::from_code("PT-BR"), Some(Language::PT_BR));
assert_eq!(Language::from_code("xx"), None);
}
#[test]
fn a_regional_language_falls_back_to_its_main_variety_before_english() {
let texts = Texts::parse(
"[en]\na = \"A\"\nb = \"B\"\n[es]\na = \"Ae\"\nb = \"Be\"\n[es-419]\na = \"Al\"\n[pt]\na = \"Ap\"\n",
)
.unwrap();
assert_eq!(texts.get("a", Language::ES_MX), Some("Al"));
assert_eq!(texts.get("b", Language::ES_MX), Some("Be"));
assert_eq!(texts.get("a", Language::PT_BR), Some("Ap"));
assert_eq!(texts.get("b", Language::PT_BR), Some("B"));
assert_eq!(texts.get("a", Language::ZH_TW), Some("A"));
assert_eq!(texts.get("a", Language(99)), Some("A"));
}
#[test]
fn missing_texts_are_named_per_language_and_a_main_variety_counts() {
let texts = Texts::parse(
"[en]\na = \"A\"\nb = \"B\"\n[pt]\na = \"Ap\"\nb = \"Bp\"\n[de]\na = \"Ad\"\n",
)
.unwrap();
let wanted = [
Language::FALLBACK,
Language::PT_BR,
Language::DE_DE,
Language::UK_UA,
];
assert_eq!(
texts.missing_in(&wanted),
[
(Language::DE_DE, "b"),
(Language::UK_UA, "a"),
(Language::UK_UA, "b")
]
);
}
#[test]
fn every_offered_language_has_a_code_and_english_is_the_fallback() {
assert_eq!(Language::OFFERED.first(), Some(&Language::FALLBACK));
assert!(!Language::OFFERED.contains(&Language::EN_US));
for language in Language::OFFERED {
assert!(!language.code().is_empty());
}
}
#[test]
fn every_language_s_lookup_order_ends_at_the_fallback_once() {
for &language in Language::ALL.iter().chain(&[Language(99)]) {
let order: Vec<Language> = language.lookup_order().collect();
assert_eq!(order.first(), Some(&language));
assert_eq!(order.last(), Some(&Language::FALLBACK));
assert_eq!(
order.iter().filter(|l| **l == Language::FALLBACK).count(),
1
);
}
}
#[test]
fn placeholders_take_their_values_and_others_stay() {
let texts =
Texts::parse("[en]\na = \"{x} and {y} and {unknown} and {\"\n[uk]\na = \"{y}, {x}\"\n")
.unwrap();
let args = [("x", "1"), ("y", "{x}")];
assert_eq!(
texts.fill("a", Language::FALLBACK, &args),
"1 and {x} and {unknown} and {"
);
assert_eq!(texts.fill("a", Language::UK_UA, &args), "{x}, 1");
}
#[test]
fn unknown_languages_and_non_text_values_are_errors() {
assert!(Texts::parse("[xx]\na = \"b\"\n").is_err());
assert!(Texts::parse("[en]\na = 1\n").is_err());
}
}