use crate::compat::{String, ToString, Vec};
use crate::i18n::global::get_manager;
use crate::i18n::types::ReloadEvent;
use crossbeam_channel::{bounded, Receiver, Sender};
use notify::{Event, EventKind, RecursiveMode, Watcher};
use std::collections::VecDeque;
use std::path::Path;
use std::time::SystemTime;
pub(crate) const I18N_RELOAD_CAPACITY: usize = 1024;
pub(crate) const I18N_RELOAD_BATCH_LIMIT: usize = 64;
pub(crate) const I18N_DRAIN_BATCH_LIMIT: usize = I18N_RELOAD_BATCH_LIMIT;
fn language_from_path(path: &Path) -> Option<String> {
path.file_stem().and_then(|s| s.to_str()).map(|s| s.to_string())
}
fn languages_for_event(kind: &EventKind, paths: &[std::path::PathBuf]) -> Vec<String> {
if !matches!(kind, EventKind::Modify(_) | EventKind::Create(_)) {
return Vec::new();
}
let mut languages: Vec<String> = Vec::new();
for path in paths {
if !path.extension().is_some_and(|ext| ext == "json") {
continue;
}
let Some(language) = language_from_path(path) else {
continue;
};
if languages.iter().any(|seen| seen == &language) {
continue;
}
languages.push(language);
}
languages
}
pub struct I18nFileWatcher {
watcher: Option<notify::RecommendedWatcher>,
reload_sender: Sender<ReloadEvent>,
reload_receiver: Receiver<ReloadEvent>,
}
impl I18nFileWatcher {
pub fn new() -> Self {
let (reload_sender, reload_receiver) = bounded(I18N_RELOAD_CAPACITY);
Self { watcher: None, reload_sender, reload_receiver }
}
pub fn watch_directory(&mut self, dir: &Path) -> Result<(), String> {
let sender = self.reload_sender.clone();
let receiver = self.reload_receiver.clone();
let mut pending: VecDeque<String> = VecDeque::new();
let mut watcher = notify::recommended_watcher(move |res: Result<Event, _>| match res {
Ok(event) => {
for language in languages_for_event(&event.kind, &event.paths) {
if pending.iter().any(|queued| queued == &language) {
continue;
}
if channel_already_holds_language(&sender, &receiver, &language) {
continue;
}
if !sender
.try_send(ReloadEvent::TranslationReloaded {
language: language.clone(),
timestamp: SystemTime::now(),
})
.is_ok()
{
pending.push_back(language);
}
}
flush_pending(&sender, &receiver, &mut pending);
}
Err(e) => {
log::error!("[i18n] Watcher error: {e:?}");
}
})
.map_err(|e| {
format!(
"i18n file watcher could not be created for '{}': {e} (the OS notify \
backend may be unavailable)",
dir.display()
)
})?;
watcher.watch(dir, RecursiveMode::NonRecursive).map_err(|e| {
format!(
"directory '{}' could not be watched for translation changes: {e} (check \
that the path exists and is readable)",
dir.display()
)
})?;
self.watcher = Some(watcher);
Ok(())
}
pub fn receiver(&self) -> &Receiver<ReloadEvent> {
&self.reload_receiver
}
pub fn sender(&self) -> &Sender<ReloadEvent> {
&self.reload_sender
}
pub fn drain_events(&self) -> Vec<ReloadEvent> {
process_reload_events_bounded(&self.reload_receiver, I18N_DRAIN_BATCH_LIMIT)
}
pub fn enable_hot_reload(&self) {
let mut guard = get_manager();
if let Some(ref mut manager) = *guard {
manager.enable_hot_reload(self.reload_sender.clone());
}
}
pub fn disable_hot_reload(&self) {
let mut guard = get_manager();
if let Some(ref mut manager) = *guard {
manager.disable_hot_reload();
}
}
pub fn is_hot_reload_enabled(&self) -> bool {
let guard = get_manager();
guard.as_ref().map(|m| m.is_hot_reload_enabled()).unwrap_or(false)
}
}
crate::impl_default_via_new!(I18nFileWatcher);
fn channel_already_holds_language(
sender: &Sender<ReloadEvent>,
receiver: &Receiver<ReloadEvent>,
language: &str,
) -> bool {
let mut found = false;
let mut requeue: VecDeque<ReloadEvent> = VecDeque::new();
while let Ok(event) = receiver.try_recv() {
if matches!(&event, ReloadEvent::TranslationReloaded { language: seen, .. } if seen == language)
{
found = true;
}
requeue.push_back(event);
}
for event in requeue {
if sender.try_send(event).is_err() {
break;
}
}
found
}
fn flush_pending(
sender: &Sender<ReloadEvent>,
receiver: &Receiver<ReloadEvent>,
pending: &mut VecDeque<String>,
) {
let mut index = 0usize;
while index < pending.len() {
let language = pending[index].clone();
if channel_already_holds_language(sender, receiver, &language) {
pending.remove(index);
continue;
}
if sender
.try_send(ReloadEvent::TranslationReloaded { language, timestamp: SystemTime::now() })
.is_ok()
{
pending.remove(index);
continue;
}
index += 1;
}
}
fn process_reload_events_bounded(
receiver: &Receiver<ReloadEvent>,
limit: usize,
) -> Vec<ReloadEvent> {
let mut events = Vec::new();
let mut languages_seen: Vec<String> = Vec::new();
let mut taken = 0usize;
while taken < limit {
let Ok(event) = receiver.try_recv() else { break };
taken += 1;
match &event {
ReloadEvent::TranslationReloaded { language, .. } => {
if languages_seen.iter().any(|seen| seen == language) {
continue;
}
languages_seen.push(language.clone());
let mut guard = get_manager();
if let Some(ref mut manager) = *guard {
match manager.reload_translation_quiet(language) {
Ok(()) => events.push(event),
Err(e) => events.push(ReloadEvent::ReloadError {
language: language.clone(),
error: e,
}),
}
}
}
ReloadEvent::ReloadError { .. } => events.push(event),
}
}
events
}
pub fn init_with_hot_reload(
options: crate::i18n::options::InitOptions,
watch_dir: Option<&Path>,
) -> (crate::i18n::options::InitReport, I18nFileWatcher) {
let diagnostics = options.diagnostics;
let mut report = crate::i18n::global::init_with_options(options);
let mut file_watcher = I18nFileWatcher::new();
if let Some(dir) = watch_dir {
if let Err(e) = file_watcher.watch_directory(dir) {
report.errors.push(format!("Failed to setup file watcher: {e}"));
} else {
file_watcher.enable_hot_reload();
if diagnostics {
log::info!("[i18n] Hot reload enabled for directory: {dir:?}");
}
}
}
(report, file_watcher)
}
pub fn process_reload_events(receiver: &Receiver<ReloadEvent>) -> Vec<ReloadEvent> {
process_reload_events_bounded(receiver, I18N_RELOAD_BATCH_LIMIT)
}
pub const HOT_RELOAD_ENV_VAR: &str = "RUST_WIDGETS_I18N_DIR";
static GLOBAL_WATCHER: crate::compat::Mutex<Option<I18nFileWatcher>> =
crate::compat::Mutex::new(None);
pub fn enable_global_hot_reload(dir: &Path) -> Result<usize, String> {
let mut loaded = 0usize;
{
let mut guard = get_manager();
if let Some(ref mut manager) = *guard {
let entries = std::fs::read_dir(dir).map_err(|e| {
format!("translation directory '{}' could not be read: {e}", dir.display())
})?;
for entry in entries.flatten() {
let path = entry.path();
if path.extension().is_some_and(|ext| ext == "json") {
let Some(path_str) = path.to_str() else { continue };
match manager.load_translations(path_str) {
Ok(()) => loaded += 1,
Err(e) => log::error!(
"[i18n] '{}' could not be loaded for hot reload: {e}",
path.display()
),
}
}
}
}
}
let mut watcher = I18nFileWatcher::new();
watcher.watch_directory(dir)?;
watcher.enable_hot_reload();
let mut guard = crate::compat::lock(&GLOBAL_WATCHER);
*guard = Some(watcher);
log::info!(
"[i18n] Hot reload watching '{}' ({loaded} translation file(s) loaded)",
dir.display()
);
Ok(loaded)
}
pub fn disable_global_hot_reload() {
let mut guard = crate::compat::lock(&GLOBAL_WATCHER);
if let Some(watcher) = guard.take() {
watcher.disable_hot_reload();
}
}
pub fn pump_hot_reload() -> usize {
let receiver = {
let guard = crate::compat::lock(&GLOBAL_WATCHER);
guard.as_ref().map(|watcher| watcher.receiver().clone())
};
match receiver {
Some(receiver) => process_reload_events(&receiver).len(),
None => 0,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn language_from_path_uses_the_file_stem() {
assert_eq!(language_from_path(Path::new("en.json")).as_deref(), Some("en"));
assert_eq!(language_from_path(Path::new("/some/dir/de.json")).as_deref(), Some("de"));
assert_eq!(language_from_path(Path::new("no-extension")).as_deref(), Some("no-extension"));
assert_eq!(language_from_path(Path::new("")), None);
}
#[test]
fn an_atomic_save_maps_the_json_path_even_when_it_is_not_first() {
use notify::event::{ModifyKind, RenameMode};
let paths = vec![
std::path::PathBuf::from("/dir/en.json.tmp-1234"),
std::path::PathBuf::from("/dir/en.json"),
];
assert_eq!(
languages_for_event(&EventKind::Modify(ModifyKind::Name(RenameMode::Both)), &paths),
vec!["en".to_string()],
"the json path must be mapped even when the temp path is first"
);
}
#[test]
fn a_created_locale_is_mapped() {
use notify::event::CreateKind;
let paths = vec![std::path::PathBuf::from("/dir/de.json")];
assert_eq!(
languages_for_event(&EventKind::Create(CreateKind::File), &paths),
vec!["de".to_string()]
);
}
#[test]
fn a_language_named_by_two_paths_is_emitted_once() {
use notify::event::{ModifyKind, RenameMode};
let paths = vec![
std::path::PathBuf::from("/dir/en.json"),
std::path::PathBuf::from("/dir/en.json"),
];
assert_eq!(
languages_for_event(&EventKind::Modify(ModifyKind::Name(RenameMode::Both)), &paths),
vec!["en".to_string()]
);
}
#[test]
fn unrelated_kinds_and_paths_map_to_nothing() {
use notify::event::{AccessKind, ModifyKind};
let json = vec![std::path::PathBuf::from("/dir/en.json")];
assert!(languages_for_event(&EventKind::Access(AccessKind::Any), &json).is_empty());
assert!(languages_for_event(
&EventKind::Modify(ModifyKind::Any),
&[std::path::PathBuf::from("/dir/notes.txt")],
)
.is_empty());
}
#[test]
fn reload_channel_is_bounded_and_delivers_every_language() {
use crate::i18n::global;
use crate::i18n::options::InitOptions;
let _lock = crate::i18n::global::global_i18n_test_lock();
let _ = global::init_with_options(InitOptions {
language: "en".to_string(),
..Default::default()
});
let watcher = I18nFileWatcher::new();
let sender = watcher.reload_sender.clone();
for i in 0..(I18N_RELOAD_CAPACITY + 500) {
let _ = sender.try_send(ReloadEvent::TranslationReloaded {
language: format!("lang-{i}"),
timestamp: SystemTime::now(),
});
}
assert!(
sender.len() <= I18N_RELOAD_CAPACITY,
"queue grew past the bound: {}",
sender.len()
);
let queued = sender.len();
let mut applied = watcher.drain_events();
assert!(!applied.is_empty(), "the first pass must do work");
assert!(applied.len() <= I18N_DRAIN_BATCH_LIMIT, "one pass is bounded");
while !applied.is_empty() {
applied = watcher.drain_events();
}
assert_eq!(sender.len(), 0, "the queue is emptied over bounded passes");
assert!(queued <= I18N_RELOAD_CAPACITY, "the queue never outgrew its bound");
}
#[test]
fn paused_consumer_resumes_with_bounded_work_and_loses_nothing() {
let watcher = I18nFileWatcher::new();
let sender = watcher.reload_sender.clone();
let pushed = I18N_DRAIN_BATCH_LIMIT * 2;
for i in 0..pushed {
sender
.try_send(ReloadEvent::ReloadError {
language: format!("lang-{i}"),
error: "synthetic backlog entry".to_string(),
})
.expect("within capacity");
}
let mut drained = 0usize;
let mut calls = 0usize;
loop {
let batch = watcher.drain_events();
assert!(
batch.len() <= I18N_DRAIN_BATCH_LIMIT,
"one drain exceeded the batch limit: {}",
batch.len()
);
if batch.is_empty() {
break;
}
drained += batch.len();
calls += 1;
}
assert_eq!(drained, pushed, "every queued event must eventually be delivered");
assert_eq!(calls, 2, "work is spread over ceil(pushed / batch_limit) calls, not one");
assert!(watcher.drain_events().is_empty(), "the queue is empty in the end");
}
}