use alloc::{
borrow::ToOwned,
boxed::Box,
collections::BTreeMap,
string::{String, ToString},
sync::Arc,
vec::Vec,
};
use core::fmt::Write;
use std::sync::Mutex;
use std::io::{Read, Write as IoWrite, Cursor, Seek};
use azul_css::{AzString, U8Vec, StringVec, OptionStringVec};
use fluent::{FluentResource, FluentValue, FluentArgs};
use fluent::concurrent::FluentBundle;
use fluent_syntax::parser;
use unic_langid::LanguageIdentifier;
use zip::{ZipArchive, ZipWriter};
use zip::write::SimpleFileOptions;
#[derive(Debug, Clone, PartialEq)]
#[repr(C)]
pub struct FluentError {
pub message: AzString,
}
impl FluentError {
pub fn new(msg: impl Into<String>) -> Self {
Self {
message: AzString::from(msg.into()),
}
}
}
#[derive(Debug, Clone, PartialEq)]
#[repr(C)]
pub struct FluentSyntaxError {
pub message: AzString,
pub line: u32,
pub column: u32,
}
#[derive(Debug, Clone, PartialEq)]
#[repr(C, u8)]
pub enum FluentSyntaxCheckResult {
Ok,
Errors(StringVec),
}
impl FluentSyntaxCheckResult {
pub fn is_ok(&self) -> bool {
match self {
FluentSyntaxCheckResult::Ok => true,
FluentSyntaxCheckResult::Errors(_) => false,
}
}
pub fn get_errors(&self) -> OptionStringVec {
match self {
FluentSyntaxCheckResult::Ok => OptionStringVec::None,
FluentSyntaxCheckResult::Errors(e) => OptionStringVec::Some(e.clone()),
}
}
}
#[derive(Debug, Clone, PartialEq)]
#[repr(C, u8)]
pub enum FluentResult {
Ok(AzString),
Err(FluentError),
}
impl FluentResult {
pub fn ok(s: impl Into<String>) -> Self {
FluentResult::Ok(AzString::from(s.into()))
}
pub fn err(msg: impl Into<String>) -> Self {
FluentResult::Err(FluentError::new(msg))
}
pub fn into_option(self) -> Option<AzString> {
match self {
FluentResult::Ok(s) => Some(s),
FluentResult::Err(_) => None,
}
}
pub fn unwrap_or(self, default: AzString) -> AzString {
match self {
FluentResult::Ok(s) => s,
FluentResult::Err(_) => default,
}
}
}
use crate::fmt::{FmtArg, FmtArgVec, FmtValue};
#[derive(Debug, Clone)]
#[repr(C)]
pub struct FluentLanguageInfo {
pub locale: AzString,
pub message_count: usize,
pub message_ids: Vec<AzString>,
}
pub type FluentLanguageInfoVec = Vec<FluentLanguageInfo>;
#[derive(Debug, Clone)]
#[repr(C)]
pub struct FluentZipLoadResult {
pub files_loaded: usize,
pub files_failed: usize,
pub errors: StringVec,
}
struct FluentLocaleBundle {
bundle: FluentBundle<FluentResource>,
sources: Vec<String>,
}
impl FluentLocaleBundle {
fn new(locale_str: &str) -> Option<Self> {
let langid: LanguageIdentifier = locale_str.parse().ok()?;
let mut bundle = FluentBundle::new_concurrent(vec![langid]);
bundle.set_use_isolating(false); Some(Self {
bundle,
sources: Vec::new(),
})
}
fn add_resource(&mut self, source: &str) -> Result<(), Vec<fluent::FluentError>> {
let resource = FluentResource::try_new(source.to_owned())
.map_err(|(_res, errors)| {
errors.into_iter().map(|e| fluent::FluentError::ParserError(e)).collect::<Vec<_>>()
})?;
self.bundle.add_resource(resource)?;
self.sources.push(source.to_owned());
Ok(())
}
fn format(&self, message_id: &str, args: &FmtArgVec) -> Option<String> {
let msg = self.bundle.get_message(message_id)?;
let pattern = msg.value()?;
let mut errors = vec![];
let fluent_args = if args.is_empty() {
None
} else {
let mut fa = FluentArgs::new();
for arg in args.iter() {
match &arg.value {
FmtValue::Str(s) => {
fa.set(arg.key.as_str().to_owned(), FluentValue::from(s.as_str().to_owned()));
}
FmtValue::Sint(n) => {
fa.set(arg.key.as_str().to_owned(), FluentValue::from(*n as f64));
}
FmtValue::Uint(n) => {
fa.set(arg.key.as_str().to_owned(), FluentValue::from(*n as f64));
}
FmtValue::Slong(n) => {
fa.set(arg.key.as_str().to_owned(), FluentValue::from(*n as f64));
}
FmtValue::Ulong(n) => {
fa.set(arg.key.as_str().to_owned(), FluentValue::from(*n as f64));
}
FmtValue::Float(n) => {
fa.set(arg.key.as_str().to_owned(), FluentValue::from(*n as f64));
}
FmtValue::Double(n) => {
fa.set(arg.key.as_str().to_owned(), FluentValue::from(*n));
}
FmtValue::Bool(b) => {
fa.set(arg.key.as_str().to_owned(), FluentValue::from(if *b { "true" } else { "false" }));
}
FmtValue::Uchar(n) => {
fa.set(arg.key.as_str().to_owned(), FluentValue::from(*n as f64));
}
FmtValue::Schar(n) => {
fa.set(arg.key.as_str().to_owned(), FluentValue::from(*n as f64));
}
FmtValue::Ushort(n) => {
fa.set(arg.key.as_str().to_owned(), FluentValue::from(*n as f64));
}
FmtValue::Sshort(n) => {
fa.set(arg.key.as_str().to_owned(), FluentValue::from(*n as f64));
}
FmtValue::Isize(n) => {
fa.set(arg.key.as_str().to_owned(), FluentValue::from(*n as f64));
}
FmtValue::Usize(n) => {
fa.set(arg.key.as_str().to_owned(), FluentValue::from(*n as f64));
}
FmtValue::StrVec(sv) => {
let joined: String = sv.iter().map(|s| s.as_str()).collect::<Vec<_>>().join(", ");
fa.set(arg.key.as_str().to_owned(), FluentValue::from(joined));
}
}
}
Some(fa)
};
let result = self.bundle.format_pattern(pattern, fluent_args.as_ref(), &mut errors);
Some(result.to_string())
}
fn has_message(&self, message_id: &str) -> bool {
self.bundle.has_message(message_id)
}
fn get_message_ids(&self) -> Vec<String> {
Vec::new()
}
}
struct FluentLocalizerInner {
bundles: Mutex<BTreeMap<String, FluentLocaleBundle>>,
default_locale: Mutex<String>,
fallback_chain: Mutex<BTreeMap<String, Vec<String>>>,
}
#[repr(C)]
#[derive(Clone)]
pub struct FluentLocalizerHandle {
ptr: Arc<FluentLocalizerInner>,
}
impl core::fmt::Debug for FluentLocalizerHandle {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
let default_locale = self.ptr.default_locale.lock()
.map(|g| g.clone())
.unwrap_or_else(|_| String::new());
f.debug_struct("FluentLocalizerHandle")
.field("default_locale", &default_locale)
.finish()
}
}
impl Default for FluentLocalizerHandle {
fn default() -> Self {
Self::create("en-US")
}
}
impl FluentLocalizerHandle {
pub fn create(default_locale: &str) -> Self {
Self {
ptr: Arc::new(FluentLocalizerInner {
bundles: Mutex::new(BTreeMap::new()),
default_locale: Mutex::new(default_locale.to_string()),
fallback_chain: Mutex::new(BTreeMap::new()),
}),
}
}
pub fn get_default_locale(&self) -> AzString {
self.ptr.default_locale.lock()
.map(|g| AzString::from(g.clone()))
.unwrap_or_else(|_| AzString::from("en-US"))
}
pub fn set_default_locale(&self, locale: &str) {
if let Ok(mut guard) = self.ptr.default_locale.lock() {
*guard = locale.to_string();
}
}
pub fn set_fallback_chain(&self, locale: &str, fallbacks: &[&str]) {
if let Ok(mut guard) = self.ptr.fallback_chain.lock() {
guard.insert(
locale.to_string(),
fallbacks.iter().map(|s| s.to_string()).collect(),
);
}
}
pub fn add_resource(&self, locale: &str, source: &str) -> bool {
if let Ok(mut bundles) = self.ptr.bundles.lock() {
let bundle = bundles
.entry(locale.to_string())
.or_insert_with(|| FluentLocaleBundle::new(locale).unwrap_or_else(|| {
FluentLocaleBundle::new("en-US").expect("en-US should always work")
}));
bundle.add_resource(source).is_ok()
} else {
false
}
}
pub fn add_resource_from_bytes(&self, locale: &str, data: &[u8]) -> bool {
match std::str::from_utf8(data) {
Ok(source) => self.add_resource(locale, source),
Err(_) => false,
}
}
pub fn load_from_zip_with_locale(&self, data: &[u8], locale_override: Option<&str>) -> FluentZipLoadResult {
let cursor = Cursor::new(data);
let mut archive = match ZipArchive::new(cursor) {
Ok(a) => a,
Err(e) => return FluentZipLoadResult {
files_loaded: 0,
files_failed: 1,
errors: StringVec::from_vec(vec![AzString::from(format!("Failed to open ZIP: {}", e))]),
},
};
let mut files_loaded = 0;
let mut files_failed = 0;
let mut errors = Vec::new();
for i in 0..archive.len() {
let mut file = match archive.by_index(i) {
Ok(f) => f,
Err(e) => {
files_failed += 1;
errors.push(AzString::from(format!("Failed to read file {}: {}", i, e)));
continue;
}
};
let name = file.name().to_string();
if file.is_dir() || !name.ends_with(".fluent") {
continue;
}
let locale = match locale_override {
Some(l) => l.to_string(),
None => {
match extract_locale_from_path(&name) {
Some(l) => l,
None => {
files_failed += 1;
errors.push(AzString::from(format!("Could not determine locale from path: {}", name)));
continue;
}
}
}
};
let mut content = String::new();
if let Err(e) = file.read_to_string(&mut content) {
files_failed += 1;
errors.push(AzString::from(format!("Failed to read {}: {}", name, e)));
continue;
}
if self.add_resource(&locale, &content) {
files_loaded += 1;
} else {
files_failed += 1;
errors.push(AzString::from(format!("Failed to parse {}", name)));
}
}
FluentZipLoadResult {
files_loaded,
files_failed,
errors: StringVec::from_vec(errors),
}
}
pub fn load_from_zip(&self, data: &[u8]) -> FluentZipLoadResult {
self.load_from_zip_with_locale(data, None)
}
pub fn load_from_zip_bytes(&self, data: &U8Vec) -> FluentZipLoadResult {
self.load_from_zip(data.as_slice())
}
pub fn load_from_zip_bytes_with_locale(&self, data: &U8Vec, locale: &str) -> FluentZipLoadResult {
self.load_from_zip_with_locale(data.as_slice(), Some(locale))
}
pub fn load_fluent_file(&self, locale: &str, content: &str) -> bool {
self.add_resource(locale, content)
}
pub fn load_fluent_file_bytes(&self, locale: &str, data: &[u8]) -> bool {
match std::str::from_utf8(data) {
Ok(source) => self.add_resource(locale, source),
Err(_) => false,
}
}
pub fn load_from_path(&self, path: &str, locale_override: Option<&str>) -> FluentZipLoadResult {
let path_obj = std::path::Path::new(path);
let data = match std::fs::read(path) {
Ok(d) => d,
Err(e) => return FluentZipLoadResult {
files_loaded: 0,
files_failed: 1,
errors: StringVec::from_vec(vec![AzString::from(format!("Failed to read file '{}': {}", path, e))]),
},
};
let locale = match locale_override {
Some(l) => Some(l.to_string()),
None => path_obj.file_stem()
.and_then(|s| s.to_str())
.and_then(|s| if looks_like_locale(s) { Some(s.to_string()) } else { None }),
};
let extension = path_obj.extension().and_then(|s| s.to_str()).unwrap_or("");
match extension {
"zip" => self.load_from_zip_with_locale(&data, locale.as_deref()),
"fluent" | "ftl" => {
let locale = match locale {
Some(l) => l,
None => return FluentZipLoadResult {
files_loaded: 0,
files_failed: 1,
errors: StringVec::from_vec(vec![AzString::from(format!("Could not determine locale from filename: {}", path))]),
},
};
match std::str::from_utf8(&data) {
Ok(content) => {
if self.add_resource(&locale, content) {
FluentZipLoadResult {
files_loaded: 1,
files_failed: 0,
errors: StringVec::from_const_slice(&[]),
}
} else {
FluentZipLoadResult {
files_loaded: 0,
files_failed: 1,
errors: StringVec::from_vec(vec![AzString::from(format!("Failed to parse {}", path))]),
}
}
}
Err(e) => FluentZipLoadResult {
files_loaded: 0,
files_failed: 1,
errors: StringVec::from_vec(vec![AzString::from(format!("Invalid UTF-8 in {}: {}", path, e))]),
},
}
}
_ => FluentZipLoadResult {
files_loaded: 0,
files_failed: 1,
errors: StringVec::from_vec(vec![AzString::from(format!("Unknown file extension: {} (expected .fluent, .ftl, or .zip)", extension))]),
},
}
}
pub fn translate(
&self,
locale: AzString,
message_id: AzString,
args: FmtArgVec,
) -> AzString {
let locale = locale.as_str();
let message_id = message_id.as_str();
if let Some(result) = self.try_translate(locale, message_id, &args) {
return result;
}
if let Ok(fallbacks) = self.ptr.fallback_chain.lock() {
if let Some(chain) = fallbacks.get(locale) {
for fallback in chain {
if let Some(result) = self.try_translate(fallback, message_id, &args) {
return result;
}
}
}
}
let default_locale = self.ptr.default_locale.lock()
.map(|g| g.clone())
.unwrap_or_else(|_| "en-US".to_string());
if locale != default_locale {
if let Some(result) = self.try_translate(&default_locale, message_id, &args) {
return result;
}
}
AzString::from(message_id.to_string())
}
fn try_translate(
&self,
locale: &str,
message_id: &str,
args: &FmtArgVec,
) -> Option<AzString> {
self.ptr.bundles.lock().ok().and_then(|bundles| {
bundles.get(locale).and_then(|bundle| {
bundle.format(message_id, args).map(AzString::from)
})
})
}
pub fn has_message(&self, locale: &str, message_id: &str) -> bool {
self.ptr.bundles.lock().ok().map(|bundles| {
bundles.get(locale).map(|b| b.has_message(message_id)).unwrap_or(false)
}).unwrap_or(false)
}
pub fn get_loaded_locales(&self) -> Vec<AzString> {
self.ptr.bundles.lock().ok().map(|bundles| {
bundles.keys().map(|k| AzString::from(k.clone())).collect()
}).unwrap_or_default()
}
pub fn get_language_info(&self) -> FluentLanguageInfoVec {
self.ptr.bundles.lock().ok().map(|bundles| {
bundles.iter().map(|(locale, bundle)| {
FluentLanguageInfo {
locale: AzString::from(locale.clone()),
message_count: bundle.sources.len(), message_ids: bundle.get_message_ids().into_iter().map(AzString::from).collect(),
}
}).collect()
}).unwrap_or_default()
}
pub fn clear_locale(&self, locale: &str) {
if let Ok(mut bundles) = self.ptr.bundles.lock() {
bundles.remove(locale);
}
}
pub fn clear_all(&self) {
if let Ok(mut bundles) = self.ptr.bundles.lock() {
bundles.clear();
}
}
}
pub fn check_fluent_syntax(source: &str) -> FluentSyntaxCheckResult {
match parser::parse(source) {
Ok(_) => FluentSyntaxCheckResult::Ok,
Err((_resource, errors)) => {
let syntax_errors: Vec<AzString> = errors.iter().map(|e| {
let message = format!("{:?}", e.kind);
let (line, column) = get_error_position(source, e.pos.start);
AzString::from(format!("{}:{}: {}", line, column, message))
}).collect();
FluentSyntaxCheckResult::Errors(syntax_errors.into())
}
}
}
pub fn check_fluent_syntax_bytes(data: &[u8]) -> FluentSyntaxCheckResult {
match std::str::from_utf8(data) {
Ok(source) => check_fluent_syntax(source),
Err(e) => FluentSyntaxCheckResult::Errors(vec![
AzString::from(format!("0:0: Invalid UTF-8: {}", e))
].into()),
}
}
fn get_error_position(source: &str, offset: usize) -> (u32, u32) {
let mut line = 1u32;
let mut column = 1u32;
for (i, ch) in source.char_indices() {
if i >= offset {
break;
}
if ch == '\n' {
line += 1;
column = 1;
} else {
column += 1;
}
}
(line, column)
}
use crate::zip::{ZipFile, ZipFileEntry, ZipWriteConfig};
pub fn create_fluent_zip(entries: Vec<ZipFileEntry>) -> Result<Vec<u8>, String> {
let zip = ZipFile { entries };
let config = ZipWriteConfig::default();
zip.to_bytes(&config).map_err(|e| e.to_string())
}
pub fn create_fluent_zip_from_strings(files: Vec<(String, String)>) -> Result<Vec<u8>, String> {
let entries: Vec<ZipFileEntry> = files
.into_iter()
.map(|(path, content)| ZipFileEntry::file(path, content.into_bytes()))
.collect();
create_fluent_zip(entries)
}
pub fn export_to_zip(localizer: &FluentLocalizerHandle) -> Result<Vec<u8>, String> {
let bundles = localizer.ptr.bundles.lock()
.map_err(|e| format!("Lock error: {:?}", e))?;
let entries: Vec<ZipFileEntry> = bundles.iter().flat_map(|(locale, bundle)| {
bundle.sources.iter().enumerate().map(|(i, source)| {
let path = if bundle.sources.len() == 1 {
format!("{}.fluent", locale)
} else {
format!("{}/part_{}.fluent", locale, i)
};
ZipFileEntry::file(path, source.clone().into_bytes())
}).collect::<Vec<_>>()
}).collect();
create_fluent_zip(entries)
}
fn extract_locale_from_path(path: &str) -> Option<String> {
let path = path.trim_start_matches('/');
let parts: Vec<&str> = path.split('/').collect();
if parts.len() >= 2 {
let potential_locale = parts[parts.len() - 2];
if looks_like_locale(potential_locale) {
return Some(potential_locale.to_string());
}
}
if let Some(filename) = parts.last() {
let name = filename.trim_end_matches(".fluent");
if looks_like_locale(name) {
return Some(name.to_string());
}
}
if parts.len() == 1 {
let name = parts[0].trim_end_matches(".fluent");
if looks_like_locale(name) {
return Some(name.to_string());
}
}
None
}
fn looks_like_locale(s: &str) -> bool {
let parts: Vec<&str> = s.split('-').collect();
if parts.is_empty() {
return false;
}
let first = parts[0];
if first.len() < 2 || first.len() > 3 {
return false;
}
if !first.chars().all(|c| c.is_ascii_alphabetic()) {
return false;
}
true
}
pub trait LayoutCallbackInfoFluentExt {
fn get_fluent_localizer(&self) -> Option<FluentLocalizerHandle>;
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_basic_translation() {
let localizer = FluentLocalizerHandle::create("en-US");
let ftl = r#"
hello = Hello, world!
greeting = Hello, { $name }!
"#;
assert!(localizer.add_resource("en-US", ftl));
let empty_args = FmtArgVec::new();
let result = localizer.translate("en-US", "hello", &empty_args);
assert_eq!(result.as_str(), "Hello, world!");
let args = FmtArgVec::from_vec(vec![FmtArg {
key: AzString::from("name"),
value: FmtValue::Str(AzString::from("Alice")),
}]);
let result = localizer.translate("en-US", "greeting", &args);
assert_eq!(result.as_str(), "Hello, Alice!");
}
#[test]
fn test_syntax_check() {
let valid = "hello = Hello, world!";
assert!(matches!(check_fluent_syntax(valid), FluentSyntaxCheckResult::Ok));
let invalid = "hello = ";
let result = check_fluent_syntax(invalid);
assert!(matches!(result, FluentSyntaxCheckResult::Errors(_)));
}
#[test]
fn test_locale_extraction() {
assert_eq!(extract_locale_from_path("en-US.fluent"), Some("en-US".to_string()));
assert_eq!(extract_locale_from_path("en-US/main.fluent"), Some("en-US".to_string()));
assert_eq!(extract_locale_from_path("locales/de-DE/errors.fluent"), Some("de-DE".to_string()));
}
#[test]
fn test_looks_like_locale() {
assert!(looks_like_locale("en"));
assert!(looks_like_locale("en-US"));
assert!(looks_like_locale("zh-Hans-CN"));
assert!(!looks_like_locale("main"));
assert!(!looks_like_locale("1234"));
}
}