use std::collections::BTreeMap;
use rustc_hash::FxHashMap;
use crate::content::Id;
use crate::locks::Relock;
use crate::{Host, Load, Off};
#[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, $doc:literal;)*) => {
impl Language {
$(#[doc = $doc] pub const $name: Self = Self($id);)*
pub const ALL: &[Self] = &[$(Self::$name),*];
#[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", "English, and every language without a text of its own.";
EN_US = 1, "en-us", "English (United States).";
EN_GB = 2, "en-gb", "English (United Kingdom).";
DE_DE = 3, "de", "German.";
NL_NL = 4, "nl", "Dutch.";
FR_FR = 5, "fr", "French.";
IT_IT = 6, "it", "Italian.";
ES_ES = 7, "es", "Spanish (Spain).";
ES_MX = 8, "es-mx", "Spanish (Mexico).";
RU_RU = 9, "ru", "Russian.";
PL_PL = 10, "pl", "Polish.";
JA_JP = 11, "ja", "Japanese.";
ID_ID = 12, "id", "Indonesian.";
KO_KR = 13, "ko", "Korean.";
MS_MY = 14, "ms", "Malay.";
PT_PT = 15, "pt", "Portuguese (Portugal).";
PT_BR = 16, "pt-br", "Portuguese (Brazil).";
TH_TH = 17, "th", "Thai.";
TR_TR = 18, "tr", "Turkish.";
VI_VN = 19, "vi", "Vietnamese.";
ZH_CN = 20, "zh-cn", "Chinese (Simplified).";
ZH_TW = 21, "zh-tw", "Chinese (Traditional).";
FR_CA = 22, "fr-ca", "French (Canada).";
ES_419 = 23, "es-419", "Spanish (Latin America).";
BG_BG = 24, "bg", "Bulgarian.";
CS_CZ = 25, "cs", "Czech.";
DA_DK = 26, "da", "Danish.";
FI_FI = 27, "fi", "Finnish.";
EL_GR = 28, "el", "Greek.";
HU_HU = 29, "hu", "Hungarian.";
NO_NO = 30, "no", "Norwegian.";
NB_NO = 31, "nb", "Norwegian (Bokmål).";
RO_RO = 32, "ro", "Romanian.";
SV_SE = 33, "sv", "Swedish.";
UK_UA = 34, "uk", "Ukrainian.";
}
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> {
let find = |language| {
self.by_language
.get(&language)
.and_then(|entries| entries.get(key))
.map(String::as_str)
};
find(language).or_else(|| find(Language::FALLBACK))
}
#[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
}
#[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 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 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 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());
}
}