use serde::Serialize;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub struct TextRef {
pub key: &'static str,
pub text: &'static str,
}
impl TextRef {
pub const fn key(key: &'static str) -> Self {
Self { key, text: "" }
}
pub const fn text(text: &'static str) -> Self {
Self { key: "", text }
}
pub const fn new(key: &'static str, text: &'static str) -> Self {
Self { key, text }
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LocalizedString {
pub value: String,
pub resource_not_found: bool,
}
impl LocalizedString {
pub fn found(value: impl Into<String>) -> Self {
Self {
value: value.into(),
resource_not_found: false,
}
}
pub fn missing(key: &str) -> Self {
Self {
value: key.to_string(),
resource_not_found: true,
}
}
}
pub trait IStringLocalizer: Send + Sync {
fn default_culture(&self) -> &str;
fn get_in(&self, culture: &str, key: &str) -> LocalizedString;
fn get(&self, key: &str) -> LocalizedString {
self.get_in(self.default_culture(), key)
}
fn resolve_in(&self, culture: &str, text: TextRef) -> LocalizedString {
if !text.key.is_empty() {
let found = self.get_in(culture, text.key);
if !found.resource_not_found {
return found;
}
if !text.text.is_empty() {
return LocalizedString::found(text.text);
}
return found;
}
LocalizedString::found(text.text)
}
fn resolve(&self, text: TextRef) -> LocalizedString {
self.resolve_in(self.default_culture(), text)
}
}
#[derive(Debug, Clone)]
pub struct LocalizationOptions {
resource_dir: Option<PathBuf>,
resources: Vec<(&'static str, &'static str)>,
}
impl Default for LocalizationOptions {
fn default() -> Self {
Self {
resource_dir: Some(PathBuf::from("locales")),
resources: Vec::new(),
}
}
}
impl LocalizationOptions {
pub fn new() -> Self {
Self::default()
}
pub fn none() -> Self {
Self {
resource_dir: None,
resources: Vec::new(),
}
}
pub fn resource_dir(mut self, dir: impl Into<PathBuf>) -> Self {
self.resource_dir = Some(dir.into());
self
}
pub fn resources(mut self, culture: &'static str, json: &'static str) -> Self {
self.resources.push((culture, json));
self
}
pub(crate) fn iter(&self) -> impl Iterator<Item = (&'static str, &'static str)> + '_ {
self.resources.iter().copied()
}
}
pub struct StringLocalizer {
default_culture: String,
resources: HashMap<String, HashMap<String, String>>,
}
impl StringLocalizer {
pub fn new(
default_culture: impl Into<String>,
options: &LocalizationOptions,
) -> Result<Self, String> {
let mut resources: HashMap<String, HashMap<String, String>> = HashMap::new();
if let Some(dir) = &options.resource_dir {
for (culture, json) in read_resource_dir(dir)? {
insert_resources(&mut resources, &culture, &json)?;
}
}
for (culture, json) in options.iter() {
insert_resources(&mut resources, culture, json)?;
}
Ok(Self {
default_culture: default_culture.into(),
resources,
})
}
fn lookup(&self, culture: &str, key: &str) -> Option<&str> {
self.resources
.get(&culture.to_ascii_lowercase())?
.get(key)
.map(String::as_str)
}
}
fn insert_resources(
resources: &mut HashMap<String, HashMap<String, String>>,
culture: &str,
json: &str,
) -> Result<(), String> {
let parsed: HashMap<String, String> =
serde_json::from_str(json).map_err(|error| format!("culture '{culture}': {error}"))?;
resources
.entry(culture.to_ascii_lowercase())
.or_default()
.extend(parsed);
Ok(())
}
fn read_resource_dir(dir: &Path) -> Result<Vec<(String, String)>, String> {
let base = if dir.is_absolute() {
dir.to_path_buf()
} else {
crate::paths::app_base().join(dir)
};
let entries = match std::fs::read_dir(&base) {
Ok(entries) => entries,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(error) => return Err(format!("{}: {error}", base.display())),
};
let mut files = Vec::new();
for entry in entries {
let entry = entry.map_err(|error| format!("{}: {error}", base.display()))?;
let path = entry.path();
if !path.is_file()
|| path.extension().and_then(|extension| extension.to_str()) != Some("json")
{
continue;
}
let Some(culture) = path.file_stem().and_then(|stem| stem.to_str()) else {
continue;
};
let json = std::fs::read_to_string(&path)
.map_err(|error| format!("{}: {error}", path.display()))?;
files.push((culture.to_string(), json));
}
files.sort_by(|a, b| a.0.cmp(&b.0));
Ok(files)
}
impl IStringLocalizer for StringLocalizer {
fn default_culture(&self) -> &str {
&self.default_culture
}
fn get_in(&self, culture: &str, key: &str) -> LocalizedString {
for candidate in culture_chain(culture) {
if let Some(value) = self.lookup(candidate, key) {
return LocalizedString::found(value);
}
}
for candidate in culture_chain(&self.default_culture) {
if let Some(value) = self.lookup(candidate, key) {
return LocalizedString::found(value);
}
}
LocalizedString::missing(key)
}
}
fn culture_chain(culture: &str) -> Vec<&str> {
let mut chain = Vec::new();
let mut current = culture;
while !current.is_empty() {
chain.push(current);
match current.rfind(['-', '_']) {
Some(index) => current = ¤t[..index],
None => break,
}
}
chain
}