use alloc::{
borrow::ToOwned,
boxed::Box,
collections::BTreeMap,
string::{String, ToString},
vec::Vec,
};
use core::fmt::Write;
use core::sync::atomic::{AtomicUsize, Ordering as AtomicOrdering};
use std::sync::Mutex;
use std::io::{Read, Write as IoWrite, Cursor, Seek};
use azul_css::{AzString, U8Vec, StringVec, OptionStringVec, impl_option, impl_option_inner, impl_vec, impl_vec_clone, impl_vec_debug};
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, 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()),
}
}
}
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, u8)]
pub enum FluentLoadError {
OpenArchive(AzString),
ReadEntry(AzString),
UnknownLocale(AzString),
ReadFile(AzString),
Parse(AzString),
InvalidUtf8(AzString),
UnknownExtension(AzString),
}
impl_option!(FluentLoadError, OptionFluentLoadError, copy = false, [Debug, Clone]);
impl_vec!(FluentLoadError, FluentLoadErrorVec, FluentLoadErrorVecDestructor, FluentLoadErrorVecDestructorType, FluentLoadErrorVecSlice, OptionFluentLoadError);
impl_vec_clone!(FluentLoadError, FluentLoadErrorVec, FluentLoadErrorVecDestructor);
impl_vec_debug!(FluentLoadError, FluentLoadErrorVec);
#[derive(Debug, Clone)]
#[repr(C)]
pub struct FluentZipLoadResult {
pub files_loaded: usize,
pub files_failed: usize,
pub errors: FluentLoadErrorVec,
}
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> {
let mut ids = Vec::new();
for source in &self.sources {
if let Ok(resource) = parser::parse(source.as_str()) {
for entry in resource.body {
if let fluent_syntax::ast::Entry::Message(msg) = entry {
ids.push(msg.id.name.to_string());
}
}
}
}
ids
}
}
pub struct FluentLocalizerInner {
bundles: Mutex<BTreeMap<String, FluentLocaleBundle>>,
default_locale: Mutex<String>,
fallback_chain: Mutex<BTreeMap<String, Vec<String>>>,
}
#[repr(C)]
pub struct FluentLocalizerHandle {
pub ptr: *const FluentLocalizerInner,
pub copies: *const AtomicUsize,
pub run_destructor: bool,
}
unsafe impl Send for FluentLocalizerHandle {}
unsafe impl Sync for FluentLocalizerHandle {}
impl Clone for FluentLocalizerHandle {
fn clone(&self) -> Self {
unsafe {
self.copies
.as_ref()
.map(|m| m.fetch_add(1, AtomicOrdering::SeqCst));
}
Self {
ptr: self.ptr,
copies: self.copies,
run_destructor: true,
}
}
}
impl Drop for FluentLocalizerHandle {
fn drop(&mut self) {
self.run_destructor = false;
unsafe {
let copies = (*self.copies).fetch_sub(1, AtomicOrdering::SeqCst);
if copies == 1 {
let _ = Box::from_raw(self.ptr as *mut FluentLocalizerInner);
let _ = Box::from_raw(self.copies as *mut AtomicUsize);
}
}
}
}
impl core::fmt::Debug for FluentLocalizerHandle {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
let inner = self.inner();
let default_locale = inner.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: Box::into_raw(Box::new(FluentLocalizerInner {
bundles: Mutex::new(BTreeMap::new()),
default_locale: Mutex::new(default_locale.to_string()),
fallback_chain: Mutex::new(BTreeMap::new()),
})),
copies: Box::into_raw(Box::new(AtomicUsize::new(1))),
run_destructor: true,
}
}
#[inline]
fn inner(&self) -> &FluentLocalizerInner {
unsafe { &*self.ptr }
}
pub fn get_default_locale(&self) -> AzString {
self.inner().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.inner().default_locale.lock() {
*guard = locale.to_string();
}
}
pub fn set_fallback_chain(&self, locale: &str, fallbacks: &[&str]) {
if let Ok(mut guard) = self.inner().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.inner().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: FluentLoadErrorVec::from_vec(vec![FluentLoadError::OpenArchive(AzString::from(format!("Failed to open ZIP: {}", e)))]),
},
};
let mut files_loaded = 0;
let mut files_failed = 0;
let mut errors: Vec<FluentLoadError> = 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(FluentLoadError::ReadEntry(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(FluentLoadError::UnknownLocale(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(FluentLoadError::ReadFile(AzString::from(format!("Failed to read {}: {}", name, e))));
continue;
}
if self.add_resource(&locale, &content) {
files_loaded += 1;
} else {
files_failed += 1;
errors.push(FluentLoadError::Parse(AzString::from(format!("Failed to parse {}", name))));
}
}
FluentZipLoadResult {
files_loaded,
files_failed,
errors: FluentLoadErrorVec::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_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: FluentLoadErrorVec::from_vec(vec![FluentLoadError::ReadFile(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: FluentLoadErrorVec::from_vec(vec![FluentLoadError::UnknownLocale(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: FluentLoadErrorVec::new(),
}
} else {
FluentZipLoadResult {
files_loaded: 0,
files_failed: 1,
errors: FluentLoadErrorVec::from_vec(vec![FluentLoadError::Parse(AzString::from(format!("Failed to parse {}", path)))]),
}
}
}
Err(e) => FluentZipLoadResult {
files_loaded: 0,
files_failed: 1,
errors: FluentLoadErrorVec::from_vec(vec![FluentLoadError::InvalidUtf8(AzString::from(format!("Invalid UTF-8 in {}: {}", path, e)))]),
},
}
}
_ => FluentZipLoadResult {
files_loaded: 0,
files_failed: 1,
errors: FluentLoadErrorVec::from_vec(vec![FluentLoadError::UnknownExtension(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.inner().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.inner().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.inner().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.inner().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.inner().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.inner().bundles.lock().ok().map(|bundles| {
bundles.iter().map(|(locale, bundle)| {
let ids: Vec<AzString> = bundle.get_message_ids().into_iter().map(AzString::from).collect();
FluentLanguageInfo {
locale: AzString::from(locale.clone()),
message_count: ids.len(),
message_ids: ids,
}
}).collect()
}).unwrap_or_default()
}
pub fn clear_locale(&self, locale: &str) {
if let Ok(mut bundles) = self.inner().bundles.lock() {
bundles.remove(locale);
}
}
pub fn clear_all(&self) {
if let Ok(mut bundles) = self.inner().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.inner().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());
}
}
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
}
#[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(AzString::from("en-US"), AzString::from("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(AzString::from("en-US"), AzString::from("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"));
}
}
#[cfg(test)]
mod autotest_generated {
use super::*;
static TMP_COUNTER: AtomicUsize = AtomicUsize::new(0);
fn tmp_dir() -> std::path::PathBuf {
let n = TMP_COUNTER.fetch_add(1, AtomicOrdering::SeqCst);
let mut p = std::env::temp_dir();
p.push(format!("azul_fluent_autotest_{}_{}", std::process::id(), n));
std::fs::create_dir_all(&p).expect("temp dir");
p
}
fn args_of(pairs: Vec<(&str, FmtValue)>) -> FmtArgVec {
FmtArgVec::from_vec(
pairs
.into_iter()
.map(|(k, v)| FmtArg {
key: AzString::from(k),
value: v,
})
.collect::<Vec<_>>(),
)
}
fn tr(h: &FluentLocalizerHandle, locale: &str, id: &str) -> String {
h.translate(
AzString::from(locale),
AzString::from(id),
FmtArgVec::new(),
)
.as_str()
.to_string()
}
#[test]
fn autotest_fluent_error_new_preserves_payload_exactly() {
assert_eq!(FluentError::new("").message.as_str(), "");
assert_eq!(FluentError::new(String::new()).message.as_str(), "");
let nul = FluentError::new("a\0b");
assert_eq!(nul.message.as_str(), "a\0b");
assert_eq!(nul.message.as_str().len(), 3);
let uni = "\u{1F600}e\u{0301}\u{202E}\u{FEFF}";
assert_eq!(FluentError::new(uni).message.as_str(), uni);
let huge = "x".repeat(1024 * 1024);
let big = FluentError::new(huge.clone());
assert_eq!(big.message.as_str().len(), huge.len());
assert_eq!(big.clone(), big);
assert_ne!(FluentError::new("a"), FluentError::new("b"));
}
#[test]
fn autotest_syntax_check_result_predicate_and_getter_stay_dual() {
let ok = FluentSyntaxCheckResult::Ok;
assert!(ok.is_ok());
assert!(matches!(ok.get_errors(), OptionStringVec::None));
let empty_errs = FluentSyntaxCheckResult::Errors(StringVec::from_vec(Vec::new()));
assert!(!empty_errs.is_ok());
match empty_errs.get_errors() {
OptionStringVec::Some(v) => assert_eq!(v.len(), 0),
OptionStringVec::None => panic!("Errors(_) must yield Some even when empty"),
}
let errs = FluentSyntaxCheckResult::Errors(StringVec::from_vec(vec![
AzString::from("1:1: bad"),
AzString::from("\u{1F600}"),
]));
assert!(!errs.is_ok());
match (errs.get_errors(), errs.get_errors()) {
(OptionStringVec::Some(a), OptionStringVec::Some(b)) => {
assert_eq!(a.as_slice(), b.as_slice());
assert_eq!(a.len(), 2);
assert_eq!(a.as_slice()[1].as_str(), "\u{1F600}");
}
_ => panic!("Errors(_) must yield Some"),
}
for r in [FluentSyntaxCheckResult::Ok, errs] {
let is_none = matches!(r.get_errors(), OptionStringVec::None);
assert_eq!(r.is_ok(), is_none);
}
}
#[test]
fn autotest_looks_like_locale_edge_and_garbage_inputs() {
for good in ["en", "de", "eng", "en-US", "zh-Hans-CN", "EN-us"] {
assert!(looks_like_locale(good), "{good:?} should look like a locale");
}
for bad in [
"", "e", "abcd", "main", "1234", "e2", "-", "-en", " en", "en ", "\u{1F600}", "0",
"-0", "\t", "\u{0301}en",
] {
assert!(!looks_like_locale(bad), "{bad:?} must be rejected");
}
assert!(looks_like_locale("inf"));
assert!(looks_like_locale("NaN"));
assert!(looks_like_locale("src"));
assert!(looks_like_locale("en-!!!!-\u{1F600}"));
assert!(!looks_like_locale(&"a".repeat(1_000_000)));
assert!(!looks_like_locale(&"a-".repeat(100_000)));
assert!(looks_like_locale(&format!("en-{}", "US-".repeat(100_000))));
}
#[test]
fn autotest_extract_locale_from_path_rejects_junk_without_panic() {
for none in [
"",
" ",
"\t\n",
"/",
"//////",
"!!!.fluent",
"1234.fluent",
".fluent",
"\u{1F600}/\u{0301}.fluent",
"valid;garbage.fluent",
] {
assert_eq!(extract_locale_from_path(none), None, "{none:?}");
}
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())
);
assert_eq!(
extract_locale_from_path("de-DE/en-US.fluent"),
Some("de-DE".to_string())
);
assert_eq!(
extract_locale_from_path("src/hello.fluent"),
Some("src".to_string())
);
assert_eq!(
extract_locale_from_path("en.fluent.fluent.fluent"),
Some("en".to_string())
);
assert_eq!(extract_locale_from_path("fr"), Some("fr".to_string()));
assert_eq!(
extract_locale_from_path("fr-FR/anything.txt"),
Some("fr-FR".to_string())
);
let long = format!("{}x.fluent", "a/".repeat(200_000));
assert_eq!(extract_locale_from_path(&long), None);
let deep = format!("{}en-US/x.fluent", "../".repeat(10_000));
assert_eq!(extract_locale_from_path(&deep), Some("en-US".to_string()));
assert_eq!(
extract_locale_from_path("en-US.fluent"),
extract_locale_from_path("en-US.fluent")
);
}
#[test]
fn autotest_locale_bundle_new_tracks_langid_parse_and_never_panics() {
let long_lang = "a".repeat(1_000_000);
let many_subtags = format!("en-{}", "US-".repeat(50_000));
let cases: Vec<&str> = vec![
"",
" ",
" ",
"\t\n",
"!!!",
"en US",
"en-US;garbage",
" en-US ",
"0",
"-0",
"9223372036854775807",
"NaN",
"inf",
"1e309",
"\u{1F600}",
"e\u{0301}n-US",
"en\u{0000}US",
"en-US\n",
"en",
"en-US",
"zh-Hans-CN",
"EN-us",
&long_lang,
&many_subtags,
];
for c in cases {
let parses = c.parse::<LanguageIdentifier>().is_ok();
assert_eq!(
FluentLocaleBundle::new(c).is_some(),
parses,
"bundle/langid disagree for {c:?}"
);
assert_eq!(FluentLocaleBundle::new(c).is_some(), parses, "{c:?}");
}
assert!(FluentLocaleBundle::new("en-US").is_some());
assert!(FluentLocaleBundle::new("en").is_some());
assert!(FluentLocaleBundle::new("zh-Hans-CN").is_some());
assert!(FluentLocaleBundle::new("!!!").is_none());
assert!(FluentLocaleBundle::new("en US").is_none());
assert!(FluentLocaleBundle::new("\u{1F600}").is_none());
assert!(FluentLocaleBundle::new(&long_lang).is_none());
}
#[test]
fn autotest_locale_bundle_add_resource_edge_sources() {
let mut b = FluentLocaleBundle::new("en-US").expect("en-US");
assert!(b.add_resource("").is_ok());
assert!(b.get_message_ids().is_empty());
assert!(b.add_resource(" \n").is_ok());
assert!(b.add_resource("\n\n\n").is_ok());
assert!(b.add_resource(" ").is_err());
assert!(b.add_resource("\t").is_err());
assert!(b.add_resource("\t\n").is_err());
for junk in [
"!!!",
"= no id",
"\u{1F600} = x",
"{",
"}",
"hello =",
"hello = { $x",
"hello = { $x ->",
"\0",
"\u{FEFF}hello = Hello",
] {
assert!(b.add_resource(junk).is_err(), "{junk:?} must not parse");
}
assert!(b.get_message_ids().is_empty());
let mut big = String::with_capacity(1_200_000);
for i in 0..40_000 {
big.push_str(&format!("m{i} = value {i}\n"));
}
assert!(b.add_resource(&big).is_ok());
assert_eq!(b.get_message_ids().len(), 40_000);
assert!(b.has_message("m39999"));
}
#[test]
fn autotest_locale_bundle_format_and_ids_positive_control() {
let mut b = FluentLocaleBundle::new("en-US").expect("en-US");
assert!(b
.add_resource("hello = Hello\ngreeting = Hi, { $name }!\n")
.is_ok());
assert!(b.has_message("hello"));
assert!(!b.has_message("nope"));
assert!(!b.has_message(""));
assert!(!b.has_message("\u{1F600}"));
assert!(!b.has_message(&"x".repeat(100_000)));
assert_eq!(
b.format("hello", &FmtArgVec::new()).as_deref(),
Some("Hello")
);
assert_eq!(b.format("missing", &FmtArgVec::new()), None);
assert_eq!(b.format("", &FmtArgVec::new()), None);
assert_eq!(b.format(&"x".repeat(100_000), &FmtArgVec::new()), None);
let unused = args_of(vec![("unused", FmtValue::Str(AzString::from("z")))]);
assert_eq!(b.format("hello", &unused).as_deref(), Some("Hello"));
assert_eq!(
b.format("greeting", &FmtArgVec::new()).as_deref(),
Some("Hi, {$name}!")
);
let named = args_of(vec![("name", FmtValue::Str(AzString::from("Alice")))]);
assert_eq!(
b.format("greeting", &named).as_deref(),
Some("Hi, Alice!")
);
assert_eq!(
b.get_message_ids(),
vec!["hello".to_string(), "greeting".to_string()]
);
}
#[test]
fn autotest_locale_bundle_format_numeric_extremes_do_not_panic() {
let mut b = FluentLocaleBundle::new("en-US").expect("en-US");
assert!(b.add_resource("v = ({ $v })\n").is_ok());
let cases: Vec<FmtValue> = vec![
FmtValue::Bool(true),
FmtValue::Bool(false),
FmtValue::Uchar(0),
FmtValue::Uchar(u8::MAX),
FmtValue::Schar(i8::MIN),
FmtValue::Schar(i8::MAX),
FmtValue::Ushort(u16::MAX),
FmtValue::Sshort(i16::MIN),
FmtValue::Uint(0),
FmtValue::Uint(u32::MAX),
FmtValue::Sint(i32::MIN),
FmtValue::Sint(i32::MAX),
FmtValue::Ulong(u64::MAX),
FmtValue::Slong(i64::MIN),
FmtValue::Slong(i64::MAX),
FmtValue::Isize(isize::MIN),
FmtValue::Isize(isize::MAX),
FmtValue::Usize(0),
FmtValue::Usize(usize::MAX),
FmtValue::Float(f32::NAN),
FmtValue::Float(f32::INFINITY),
FmtValue::Float(f32::NEG_INFINITY),
FmtValue::Float(f32::MIN_POSITIVE),
FmtValue::Float(-0.0),
FmtValue::Double(f64::NAN),
FmtValue::Double(f64::INFINITY),
FmtValue::Double(f64::NEG_INFINITY),
FmtValue::Double(f64::MAX),
FmtValue::Double(f64::MIN_POSITIVE),
FmtValue::Double(-0.0),
FmtValue::Str(AzString::from("")),
FmtValue::Str(AzString::from("\u{1F600}e\u{0301}")),
FmtValue::Str(AzString::from("{ $v }")),
FmtValue::StrVec(StringVec::from_vec(Vec::new())),
FmtValue::StrVec(StringVec::from_vec(vec![
AzString::from("a"),
AzString::from("b"),
])),
];
for v in cases {
let out = b
.format("v", &args_of(vec![("v", v.clone())]))
.expect("message `v` exists, so format() must return Some");
assert!(
out.starts_with('(') && out.ends_with(')'),
"{v:?} formatted to {out:?}"
);
}
assert_eq!(
b.format("v", &args_of(vec![("v", FmtValue::Double(42.0))]))
.as_deref(),
Some("(42)")
);
assert_eq!(
b.format("v", &args_of(vec![("v", FmtValue::Bool(true))]))
.as_deref(),
Some("(true)")
);
assert_eq!(
b.format("v", &args_of(vec![("v", FmtValue::Slong(1_234_567))]))
.as_deref(),
Some("(1234567)")
);
assert_eq!(
b.format(
"v",
&args_of(vec![(
"v",
FmtValue::StrVec(StringVec::from_vec(vec![
AzString::from("a"),
AzString::from("b"),
]))
)])
)
.as_deref(),
Some("(a, b)")
);
let out = b
.format("v", &args_of(vec![("v", FmtValue::Slong(i64::MAX))]))
.expect("some");
assert_ne!(out, format!("({})", i64::MAX));
assert_eq!(out, "(9223372036854775808)");
}
#[test]
fn autotest_locale_bundle_format_select_expression_nan_and_inf() {
let mut b = FluentLocaleBundle::new("en-US").expect("en-US");
assert!(b
.add_resource("emails = { $count ->\n [one] one email\n *[other] many emails\n}\n")
.is_ok());
assert_eq!(
b.format("emails", &args_of(vec![("count", FmtValue::Uint(1))]))
.as_deref(),
Some("one email")
);
assert_eq!(
b.format("emails", &args_of(vec![("count", FmtValue::Uint(5))]))
.as_deref(),
Some("many emails")
);
for weird in [
FmtValue::Double(f64::NAN),
FmtValue::Double(f64::INFINITY),
FmtValue::Double(f64::NEG_INFINITY),
FmtValue::Double(f64::MAX),
FmtValue::Double(f64::MIN_POSITIVE),
FmtValue::Float(f32::NAN),
FmtValue::Slong(i64::MIN),
FmtValue::Ulong(u64::MAX),
] {
assert_eq!(
b.format("emails", &args_of(vec![("count", weird.clone())]))
.as_deref(),
Some("many emails"),
"{weird:?}"
);
}
}
#[test]
fn autotest_locale_bundle_format_duplicate_and_bulk_args() {
let mut b = FluentLocaleBundle::new("en-US").expect("en-US");
assert!(b.add_resource("v = ({ $v })\n").is_ok());
let dup = FmtArgVec::from_vec(vec![
FmtArg {
key: AzString::from("v"),
value: FmtValue::Sint(1),
},
FmtArg {
key: AzString::from("v"),
value: FmtValue::Sint(2),
},
]);
assert_eq!(b.format("v", &dup).as_deref(), Some("(2)"));
let empty_key = args_of(vec![("", FmtValue::Sint(7))]);
assert_eq!(b.format("v", &empty_key).as_deref(), Some("({$v})"));
let many = FmtArgVec::from_vec(
(0..10_000usize)
.map(|i| FmtArg {
key: AzString::from(format!("k{i}")),
value: FmtValue::Usize(i),
})
.collect::<Vec<_>>(),
);
assert_eq!(b.format("v", &many).as_deref(), Some("({$v})"));
}
#[test]
fn autotest_get_error_position_boundaries_and_saturation() {
assert_eq!(get_error_position("", 0), (1, 1));
assert_eq!(get_error_position("", usize::MAX), (1, 1));
assert_eq!(get_error_position("abc", 0), (1, 1));
assert_eq!(get_error_position("abc", 1), (1, 2));
assert_eq!(get_error_position("abc", 3), (1, 4));
assert_eq!(get_error_position("abc", usize::MAX), (1, 4));
assert_eq!(get_error_position("a\nb", 1), (1, 2));
assert_eq!(get_error_position("a\nb", 2), (2, 1));
assert_eq!(get_error_position("a\nb", 3), (2, 2));
assert_eq!(get_error_position("\n\n\n", usize::MAX), (4, 1));
assert_eq!(get_error_position("a\r\nb", 3), (2, 1));
assert_eq!(get_error_position("\u{1F600}x", 0), (1, 1));
assert_eq!(get_error_position("\u{1F600}x", 1), (1, 2)); assert_eq!(get_error_position("\u{1F600}x", 3), (1, 2)); assert_eq!(get_error_position("\u{1F600}x", 4), (1, 2));
assert_eq!(get_error_position("\u{1F600}x", 5), (1, 3));
assert_eq!(get_error_position("e\u{0301}", usize::MAX), (1, 3));
assert_eq!(
get_error_position(&"a".repeat(200_000), usize::MAX),
(1, 200_001)
);
assert_eq!(
get_error_position(&"\n".repeat(200_000), usize::MAX),
(200_001, 1)
);
}
#[test]
fn autotest_check_fluent_syntax_edge_sources() {
assert!(check_fluent_syntax("").is_ok());
assert!(check_fluent_syntax("hello = Hello, world!").is_ok());
assert!(check_fluent_syntax("# just a comment\n").is_ok());
assert!(check_fluent_syntax(" \n").is_ok());
assert!(check_fluent_syntax("\n\n\n").is_ok());
assert!(!check_fluent_syntax(" ").is_ok());
assert!(!check_fluent_syntax("\t\n").is_ok());
for src in [
"\u{1F600}",
"\u{0301}",
"\0\0\0",
"= = =",
"{{{{",
"]]]]",
"-",
"hello = { $x",
"valid = 1;garbage",
] {
let a = check_fluent_syntax(src).is_ok();
let b = check_fluent_syntax(src).is_ok();
assert_eq!(a, b, "{src:?} must be deterministic");
}
let r = check_fluent_syntax("ok = 1\nbad line here\n");
match r.get_errors() {
OptionStringVec::Some(e) => {
assert!(!e.is_empty());
assert!(
e.as_slice()[0].as_str().starts_with("2:"),
"{:?}",
e.as_slice()[0]
);
}
OptionStringVec::None => panic!("expected a syntax error"),
}
let mut big = String::with_capacity(600_000);
for i in 0..40_000 {
big.push_str(&format!("m{i} = v{i}\n"));
}
assert!(check_fluent_syntax(&big).is_ok());
let mut junky = String::new();
for i in 0..2_000 {
junky.push_str(&format!("ok{i} = v\n!!!\n"));
}
assert!(!check_fluent_syntax(&junky).is_ok());
}
#[test]
fn autotest_check_fluent_syntax_bytes_invalid_utf8_and_bom() {
assert!(check_fluent_syntax_bytes(b"").is_ok());
assert!(check_fluent_syntax_bytes(b"hello = Hello").is_ok());
let r = check_fluent_syntax_bytes(&[0xFF, 0xFE, 0x00]);
match r.get_errors() {
OptionStringVec::Some(e) => {
assert_eq!(e.len(), 1);
assert!(e.as_slice()[0].as_str().starts_with("0:0: Invalid UTF-8"));
}
OptionStringVec::None => panic!("invalid UTF-8 must be an error"),
}
assert!(!check_fluent_syntax_bytes(&[0xE2, 0x82]).is_ok());
assert!(!check_fluent_syntax_bytes("\u{FEFF}hello = Hello".as_bytes()).is_ok());
}
#[test]
fn autotest_check_fluent_syntax_deep_placeable_nesting_does_not_overflow() {
let shallow = check_fluent_syntax("m = {{{\"x\"}}}").is_ok();
let depth = 1_000;
let src = format!("m = {}\"x\"{}", "{".repeat(depth), "}".repeat(depth));
let deep = std::thread::Builder::new()
.stack_size(32 * 1024 * 1024)
.spawn(move || check_fluent_syntax(&src).is_ok())
.expect("spawn")
.join()
.expect("parser must not crash on deeply nested placeables");
assert_eq!(deep, shallow, "nesting depth must not change validity");
}
#[test]
fn autotest_localizer_default_locale_roundtrip_and_garbage() {
let h = FluentLocalizerHandle::create("en-US");
assert_eq!(h.get_default_locale().as_str(), "en-US");
for junk in ["", " ", "!!!", "\u{1F600}", "en\0US", "\u{0301}"] {
let g = FluentLocalizerHandle::create(junk);
assert_eq!(g.get_default_locale().as_str(), junk);
}
let long = "x".repeat(100_000);
let g = FluentLocalizerHandle::create(&long);
assert_eq!(g.get_default_locale().as_str(), long.as_str());
h.set_default_locale("de-DE");
assert_eq!(h.get_default_locale().as_str(), "de-DE");
let c = h.clone();
c.set_default_locale("fr-FR");
assert_eq!(h.get_default_locale().as_str(), "fr-FR");
assert!(core::ptr::eq(h.inner(), c.inner()));
assert_eq!(
FluentLocalizerHandle::default().get_default_locale().as_str(),
"en-US"
);
assert!(format!("{h:?}").contains("fr-FR"));
}
#[test]
fn autotest_localizer_clone_drop_refcount_is_sound() {
let h = FluentLocalizerHandle::create("en-US");
assert!(h.add_resource("en-US", "k = v\n"));
let clones: Vec<FluentLocalizerHandle> = (0..1_000).map(|_| h.clone()).collect();
for c in &clones {
assert_eq!(tr(c, "en-US", "k"), "v");
}
drop(clones);
assert_eq!(tr(&h, "en-US", "k"), "v");
let shared = h.clone();
let workers: Vec<_> = (0..8)
.map(|i| {
let s = shared.clone();
std::thread::spawn(move || {
for _ in 0..50 {
s.add_resource(&format!("l{i}"), &format!("m{i} = v{i}\n"));
let _ = s.get_loaded_locales();
let _ = s.get_language_info();
let _ = tr(&s, "en-US", "k");
let _ = s.clone();
}
})
})
.collect();
for w in workers {
w.join().expect("worker thread panicked");
}
assert_eq!(tr(&h, "en-US", "k"), "v");
}
#[test]
fn autotest_localizer_invalid_locale_silently_falls_back_to_an_en_us_bundle() {
let h = FluentLocalizerHandle::create("en-US");
assert!(h.add_resource("!!!", "k = v\n"));
assert!(h.has_message("!!!", "k"));
assert_eq!(
h.get_loaded_locales()
.iter()
.map(|s| s.as_str().to_string())
.collect::<Vec<_>>(),
vec!["!!!".to_string()]
);
assert_eq!(tr(&h, "!!!", "k"), "v");
assert!(h.add_resource("", "e = 1\n"));
assert!(h.has_message("", "e"));
assert!(h.add_resource("\u{1F600}", "u = 2\n"));
assert!(h.has_message("\u{1F600}", "u"));
assert!(!h.add_resource("en-US", "!!!"));
assert!(!h.add_resource("en-US", "hello ="));
assert!(h.add_resource("en-US", ""));
}
#[test]
fn autotest_localizer_add_resource_from_bytes_rejects_invalid_utf8() {
let h = FluentLocalizerHandle::create("en-US");
assert!(h.add_resource_from_bytes("en-US", b"k = v\n"));
assert!(h.has_message("en-US", "k"));
assert!(h.add_resource_from_bytes("en-US", b""));
assert!(!h.add_resource_from_bytes("de-DE", &[0xFF, 0xFF]));
assert!(!h.add_resource_from_bytes("de-DE", &[0xE2, 0x82]));
assert!(!h.add_resource_from_bytes("de-DE", &[0x80]));
assert!(!h.has_message("de-DE", "k"));
assert_eq!(h.get_loaded_locales().len(), 1);
}
#[test]
fn autotest_localizer_duplicate_ids_rejected_and_partial_adds_go_untracked() {
let h = FluentLocalizerHandle::create("en-US");
assert!(h.add_resource("en-US", "a = 1\n"));
assert!(!h.add_resource("en-US", "a = 2\n"));
assert_eq!(tr(&h, "en-US", "a"), "1");
assert!(!h.add_resource("en-US", "a = 3\nb = 9\n"));
assert!(h.has_message("en-US", "b"));
assert_eq!(tr(&h, "en-US", "b"), "9");
let info = h.get_language_info();
assert_eq!(info.len(), 1);
let ids: Vec<&str> = info[0].message_ids.iter().map(|s| s.as_str()).collect();
assert_eq!(
ids,
vec!["a"],
"`b` is live in the bundle but missing from the tracked sources"
);
assert_eq!(info[0].message_count, 1);
let zip = export_to_zip(&h).expect("export");
let h2 = FluentLocalizerHandle::create("en-US");
let r = h2.load_from_zip(&zip);
assert_eq!(r.files_failed, 0, "{:?}", r.errors);
assert!(h2.has_message("en-US", "a"));
assert!(
!h2.has_message("en-US", "b"),
"round-trip silently drops the partially-added message"
);
}
#[test]
fn autotest_translate_fallback_chain_terminates_on_cycles() {
let h = FluentLocalizerHandle::create("en-US");
assert!(h.add_resource("en-US", "only-en = EN\n"));
assert!(h.add_resource("de-DE", "only-de = DE\n"));
assert!(h.add_resource("de-CH", "only-ch = CH\n"));
h.set_fallback_chain("de-CH", &["de-DE", "en-US"]);
assert_eq!(tr(&h, "de-CH", "only-ch"), "CH"); assert_eq!(tr(&h, "de-CH", "only-de"), "DE"); assert_eq!(tr(&h, "de-CH", "only-en"), "EN");
assert_eq!(tr(&h, "de-CH", "missing"), "missing");
assert_eq!(tr(&h, "de-CH", ""), "");
assert_eq!(tr(&h, "de-CH", "\u{1F600}"), "\u{1F600}");
assert_eq!(tr(&h, "de-CH", " spaced "), " spaced ");
assert_eq!(tr(&h, "xx-XX", "only-en"), "EN");
assert_eq!(tr(&h, "", "only-en"), "EN");
assert_eq!(tr(&h, &"z".repeat(100_000), "only-en"), "EN");
h.set_fallback_chain("de-CH", &[]);
assert_eq!(tr(&h, "de-CH", "only-de"), "only-de");
assert_eq!(tr(&h, "de-CH", "only-en"), "EN");
let many: Vec<String> = (0..10_000).map(|i| format!("l{i}")).collect();
let refs: Vec<&str> = many.iter().map(|s| s.as_str()).collect();
h.set_fallback_chain("zz-ZZ", &refs);
assert_eq!(tr(&h, "zz-ZZ", "only-en"), "EN");
}
#[test]
fn autotest_translate_fallback_chain_is_one_hop_and_cycle_safe() {
let g = FluentLocalizerHandle::create("qq-QQ");
assert!(g.add_resource("en-US", "only-en = EN\n"));
assert!(g.add_resource("de-DE", "only-de = DE\n"));
assert!(g.add_resource("de-CH", "only-ch = CH\n"));
g.set_fallback_chain("de-CH", &["de-DE"]);
g.set_fallback_chain("de-DE", &["en-US"]);
assert_eq!(tr(&g, "de-CH", "only-ch"), "CH"); assert_eq!(tr(&g, "de-CH", "only-de"), "DE"); assert_eq!(tr(&g, "de-CH", "only-en"), "only-en");
assert_eq!(tr(&g, "qq-QQ", "only-en"), "only-en");
g.set_fallback_chain("de-DE", &["de-DE"]);
assert_eq!(tr(&g, "de-DE", "missing"), "missing");
g.set_fallback_chain("de-CH", &["de-DE"]);
g.set_fallback_chain("de-DE", &["de-CH"]);
assert_eq!(tr(&g, "de-CH", "missing"), "missing");
assert_eq!(tr(&g, "de-DE", "missing"), "missing");
assert_eq!(tr(&g, "de-CH", "only-de"), "DE");
assert_eq!(tr(&g, "de-DE", "only-ch"), "CH");
}
#[test]
fn autotest_try_translate_is_strict_about_the_locale_key() {
let h = FluentLocalizerHandle::create("en-US");
assert!(h.add_resource("en-US", "k = v\n"));
let empty = FmtArgVec::new();
assert_eq!(
h.try_translate("en-US", "k", &empty).map(|s| s.as_str().to_string()),
Some("v".to_string())
);
assert_eq!(h.try_translate("en", "k", &empty), None);
assert_eq!(h.try_translate("EN-US", "k", &empty), None);
assert_eq!(h.try_translate(" en-US ", "k", &empty), None);
assert_eq!(h.try_translate("", "k", &empty), None);
assert_eq!(h.try_translate("\u{1F600}", "k", &empty), None);
assert_eq!(h.try_translate(&"x".repeat(100_000), "k", &empty), None);
assert_eq!(h.try_translate("en-US", "nope", &empty), None);
assert_eq!(h.try_translate("en-US", "", &empty), None);
}
#[test]
fn autotest_localizer_has_message_edge_inputs() {
let h = FluentLocalizerHandle::create("en-US");
assert!(h.add_resource("en-US", "k = v\n"));
assert!(h.has_message("en-US", "k"));
assert!(!h.has_message("en-US", "K")); assert!(!h.has_message("en-US", "k ")); assert!(!h.has_message("en-US", " k"));
assert!(!h.has_message("en-US", ""));
assert!(!h.has_message("", "k")); assert!(!h.has_message("EN-US", "k")); assert!(!h.has_message("en-US", &"k".repeat(100_000)));
assert!(!h.has_message(&"x".repeat(100_000), "k"));
assert_eq!(h.get_loaded_locales().len(), 1);
}
#[test]
fn autotest_localizer_getters_on_an_empty_instance() {
let h = FluentLocalizerHandle::create("en-US");
assert!(h.get_loaded_locales().is_empty());
assert!(h.get_language_info().is_empty());
assert_eq!(h.get_default_locale().as_str(), "en-US");
assert!(!h.has_message("en-US", "anything"));
assert_eq!(tr(&h, "en-US", "anything"), "anything");
let empty_zip = export_to_zip(&h).expect("empty export");
let r = h.load_from_zip(&empty_zip);
assert_eq!((r.files_loaded, r.files_failed), (0, 0));
h.clear_locale("en-US");
h.clear_locale("");
h.clear_locale("\u{1F600}");
h.clear_all();
h.clear_all();
assert!(h.get_loaded_locales().is_empty());
}
#[test]
fn autotest_localizer_clear_locale_and_clear_all() {
let h = FluentLocalizerHandle::create("en-US");
assert!(h.add_resource("en-US", "k = v\n"));
assert!(h.add_resource("de-DE", "k = w\n"));
assert_eq!(h.get_loaded_locales().len(), 2);
h.clear_locale("fr-FR"); assert_eq!(h.get_loaded_locales().len(), 2);
h.clear_locale("de-DE");
assert_eq!(h.get_loaded_locales().len(), 1);
assert!(!h.has_message("de-DE", "k"));
assert!(h.has_message("en-US", "k"));
assert_eq!(h.get_default_locale().as_str(), "en-US");
h.clear_all();
assert!(h.get_loaded_locales().is_empty());
assert!(h.get_language_info().is_empty());
assert_eq!(tr(&h, "en-US", "k"), "k");
assert!(h.add_resource("en-US", "k = v2\n"));
assert_eq!(tr(&h, "en-US", "k"), "v2");
}
#[test]
fn autotest_localizer_get_language_info_reports_ids_per_locale() {
let h = FluentLocalizerHandle::create("en-US");
assert!(h.add_resource("en-US", "a = A\nb = B\n"));
assert!(h.add_resource("de-DE", "c = C\n"));
let info = h.get_language_info();
assert_eq!(info.len(), 2);
assert_eq!(info[0].locale.as_str(), "de-DE");
assert_eq!(info[0].message_count, 1);
assert_eq!(info[1].locale.as_str(), "en-US");
assert_eq!(info[1].message_count, 2);
for i in &info {
assert_eq!(i.message_count, i.message_ids.len());
}
let en: Vec<&str> = info[1].message_ids.iter().map(|s| s.as_str()).collect();
assert_eq!(en, vec!["a", "b"]);
}
#[test]
fn autotest_zip_export_import_roundtrip_is_lossless() {
let h = FluentLocalizerHandle::create("en-US");
assert!(h.add_resource("de-DE", "a = A\n"));
assert!(h.add_resource("de-DE", "b = B\n"));
assert!(h.add_resource("en-US", "c = C\n"));
let bytes = export_to_zip(&h).expect("export");
let h2 = FluentLocalizerHandle::create("en-US");
let res = h2.load_from_zip(&bytes);
assert_eq!(res.files_failed, 0, "{:?}", res.errors);
assert_eq!(res.files_loaded, 3);
for (loc, id, want) in [("de-DE", "a", "A"), ("de-DE", "b", "B"), ("en-US", "c", "C")] {
assert!(h2.has_message(loc, id), "{loc}/{id}");
assert_eq!(tr(&h2, loc, id), want);
}
let mut locales: Vec<String> = h2
.get_loaded_locales()
.iter()
.map(|s| s.as_str().to_string())
.collect();
locales.sort();
assert_eq!(locales, vec!["de-DE".to_string(), "en-US".to_string()]);
let bytes2 = export_to_zip(&h2).expect("re-export");
let h3 = FluentLocalizerHandle::create("en-US");
assert_eq!(h3.load_from_zip(&bytes2).files_loaded, 3);
assert_eq!(tr(&h3, "de-DE", "b"), "B");
}
#[test]
fn autotest_create_fluent_zip_edge_entries() {
let h = FluentLocalizerHandle::create("en-US");
let empty = create_fluent_zip(Vec::new()).expect("empty zip");
let r = h.load_from_zip(&empty);
assert_eq!((r.files_loaded, r.files_failed), (0, 0));
let r = h.load_from_zip_bytes(&U8Vec::from_vec(empty.clone()));
assert_eq!((r.files_loaded, r.files_failed), (0, 0));
let zip = create_fluent_zip_from_strings(vec![
("README.md".to_string(), "not a translation".to_string()),
("en-US.fluent".to_string(), "k = v\n".to_string()),
])
.expect("zip");
let r = h.load_from_zip(&zip);
assert_eq!((r.files_loaded, r.files_failed), (1, 0));
assert_eq!(tr(&h, "en-US", "k"), "v");
let zip = create_fluent_zip_from_strings(vec![
("whatever-long-name.fluent".to_string(), "x = 1\n".to_string()),
("fr-FR.fluent".to_string(), "y = 2\n".to_string()),
])
.expect("zip");
let r = h.load_from_zip(&zip);
assert_eq!(r.files_loaded, 1);
assert_eq!(r.files_failed, 1);
assert_eq!(r.errors.len(), 1);
assert!(matches!(
r.errors.as_slice()[0],
FluentLoadError::UnknownLocale(_)
));
assert_eq!(tr(&h, "fr-FR", "y"), "2");
let r = h.load_from_zip_bytes_with_locale(&U8Vec::from_vec(zip), "es-ES");
assert_eq!(r.files_failed, 0, "{:?}", r.errors);
assert_eq!(r.files_loaded, 2);
assert_eq!(tr(&h, "es-ES", "x"), "1");
assert_eq!(tr(&h, "es-ES", "y"), "2");
let zip = create_fluent_zip_from_strings(vec![(
"it-IT.fluent".to_string(),
"!!! broken\n".to_string(),
)])
.expect("zip");
let r = h.load_from_zip(&zip);
assert_eq!((r.files_loaded, r.files_failed), (0, 1));
assert!(matches!(r.errors.as_slice()[0], FluentLoadError::Parse(_)));
}
#[test]
fn autotest_load_from_zip_rejects_malformed_archives() {
let h = FluentLocalizerHandle::create("en-US");
for data in [
&b""[..],
&b"not a zip at all"[..],
&[0x50, 0x4B, 0x03, 0x04][..], &[0xFFu8; 64][..],
] {
let r = h.load_from_zip(data);
assert_eq!(r.files_loaded, 0);
assert_eq!(r.files_failed, 1);
assert_eq!(r.errors.len(), 1);
assert!(matches!(
r.errors.as_slice()[0],
FluentLoadError::OpenArchive(_)
));
}
let big = vec![0u8; 4 * 1024 * 1024];
let r = h.load_from_zip(&big);
assert_eq!((r.files_loaded, r.files_failed), (0, 1));
assert!(h.get_loaded_locales().is_empty());
}
#[test]
fn autotest_load_from_path_edge_cases() {
let h = FluentLocalizerHandle::create("en-US");
let dir = tmp_dir();
let missing = dir.join("en-US.fluent");
let r = h.load_from_path(missing.to_str().expect("utf8"), None);
assert_eq!((r.files_loaded, r.files_failed), (0, 1));
assert!(matches!(r.errors.as_slice()[0], FluentLoadError::ReadFile(_)));
for bad in [
String::new(),
dir.to_str().expect("utf8").to_string(),
"a\0b.fluent".to_string(),
format!("/{}.fluent", "p".repeat(9_000)),
] {
let r = h.load_from_path(&bad, None);
assert_eq!(r.files_failed, 1, "{bad:?}");
assert_eq!(r.files_loaded, 0, "{bad:?}");
}
let p = dir.join("translations.fluent");
std::fs::write(&p, b"k = v\n").expect("write");
let r = h.load_from_path(p.to_str().expect("utf8"), None);
assert_eq!((r.files_loaded, r.files_failed), (0, 1));
assert!(matches!(
r.errors.as_slice()[0],
FluentLoadError::UnknownLocale(_)
));
let r = h.load_from_path(p.to_str().expect("utf8"), Some("de-DE"));
assert_eq!((r.files_loaded, r.files_failed), (1, 0));
assert_eq!(tr(&h, "de-DE", "k"), "v");
let t = dir.join("en-US.txt");
std::fs::write(&t, b"k = v\n").expect("write");
let r = h.load_from_path(t.to_str().expect("utf8"), None);
assert_eq!((r.files_loaded, r.files_failed), (0, 1));
assert!(matches!(
r.errors.as_slice()[0],
FluentLoadError::UnknownExtension(_)
));
let n = dir.join("en-US");
std::fs::write(&n, b"k = v\n").expect("write");
let r = h.load_from_path(n.to_str().expect("utf8"), None);
assert_eq!((r.files_loaded, r.files_failed), (0, 1));
assert!(matches!(
r.errors.as_slice()[0],
FluentLoadError::UnknownExtension(_)
));
let u = dir.join("es-ES.ftl");
std::fs::write(&u, [0xFFu8, 0xFE, 0xFD]).expect("write");
let r = h.load_from_path(u.to_str().expect("utf8"), None);
assert_eq!((r.files_loaded, r.files_failed), (0, 1));
assert!(matches!(
r.errors.as_slice()[0],
FluentLoadError::InvalidUtf8(_)
));
let b = dir.join("fr-FR.ftl");
std::fs::write(&b, b"!!! broken\n").expect("write");
let r = h.load_from_path(b.to_str().expect("utf8"), None);
assert_eq!((r.files_loaded, r.files_failed), (0, 1));
assert!(matches!(r.errors.as_slice()[0], FluentLoadError::Parse(_)));
let z = dir.join("pack.zip");
let zip = create_fluent_zip_from_strings(vec![(
"it-IT.fluent".to_string(),
"k = v\n".to_string(),
)])
.expect("zip");
std::fs::write(&z, &zip).expect("write");
let r = h.load_from_path(z.to_str().expect("utf8"), None);
assert_eq!((r.files_loaded, r.files_failed), (1, 0));
assert_eq!(tr(&h, "it-IT", "k"), "v");
let _ = std::fs::remove_dir_all(&dir);
}
}