Skip to main content

ferroui_protranslate_core/
service.rs

1use crate::event::Event;
2use crate::*;
3use std::{
4    cell::{Cell, RefCell},
5    collections::{HashMap, HashSet, VecDeque},
6    rc::Rc,
7};
8
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub struct TranslationFallbackOptions {
11    pub default_culture: Culture,
12    pub fallback_cultures: Vec<Culture>,
13    pub use_parent_cultures: bool,
14    pub use_default_culture: bool,
15    pub return_key_when_missing: bool,
16    pub provider_failure_behavior: TranslationProviderFailureBehavior,
17    pub format_failure_behavior: TranslationFormatFailureBehavior,
18}
19impl Default for TranslationFallbackOptions {
20    fn default() -> Self {
21        Self {
22            default_culture: Culture::invariant(),
23            fallback_cultures: Vec::new(),
24            use_parent_cultures: true,
25            use_default_culture: true,
26            return_key_when_missing: true,
27            provider_failure_behavior: Default::default(),
28            format_failure_behavior: Default::default(),
29        }
30    }
31}
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub struct TranslationCacheOptions {
34    pub enabled: bool,
35    pub cache_missing_translations: bool,
36    pub clear_on_culture_changed: bool,
37    /// Zero is unbounded, matching the source policy. Finite caches evict FIFO.
38    pub maximum_entries: usize,
39}
40impl Default for TranslationCacheOptions {
41    fn default() -> Self {
42        Self {
43            enabled: true,
44            cache_missing_translations: true,
45            clear_on_culture_changed: true,
46            maximum_entries: 0,
47        }
48    }
49}
50type CacheKey = (Culture, String);
51#[derive(Default)]
52struct Cache {
53    values: HashMap<CacheKey, LocalizedString>,
54    order: VecDeque<CacheKey>,
55    provider_revision: u64,
56    epoch: u64,
57}
58impl Cache {
59    fn clear(&mut self) {
60        self.epoch = self.epoch.wrapping_add(1);
61        self.values.clear();
62        self.order.clear();
63    }
64}
65struct TranslationServiceInner {
66    provider: Rc<dyn TranslationProvider>,
67    cultures: CultureService,
68    generation: Cell<u64>,
69    options: RefCell<TranslationFallbackOptions>,
70    cache_options: RefCell<TranslationCacheOptions>,
71    cache: RefCell<Cache>,
72    changed: Event<CultureChangedEventArgs>,
73    diagnostics: Event<ProTranslateDiagnostic>,
74    diagnostic_sink: RefCell<Option<Rc<dyn ProTranslateDiagnosticSink>>>,
75    _culture_subscription: Subscription,
76}
77#[derive(Clone)]
78pub struct TranslationService(Rc<TranslationServiceInner>);
79impl TranslationService {
80    pub fn new(provider: Rc<dyn TranslationProvider>, cultures: CultureService) -> Self {
81        Self::with_options(provider, cultures, Default::default(), Default::default())
82    }
83    pub fn with_options(
84        provider: Rc<dyn TranslationProvider>,
85        cultures: CultureService,
86        options: TranslationFallbackOptions,
87        cache_options: TranslationCacheOptions,
88    ) -> Self {
89        Self(Rc::new_cyclic(
90            |weak: &std::rc::Weak<TranslationServiceInner>| {
91                let weak = weak.clone();
92                let subscription = cultures.subscribe(move |event| {
93                    if let Some(service) = weak.upgrade() {
94                        service
95                            .generation
96                            .set(service.generation.get().wrapping_add(1));
97                        if service.cache_options.borrow().clear_on_culture_changed {
98                            service.cache.borrow_mut().clear();
99                        }
100                        service.changed.emit(event.clone());
101                    }
102                });
103                TranslationServiceInner {
104                    provider,
105                    cultures,
106                    generation: Cell::new(0),
107                    options: RefCell::new(options),
108                    cache_options: RefCell::new(cache_options),
109                    cache: RefCell::new(Cache::default()),
110                    changed: Event::default(),
111                    diagnostics: Event::default(),
112                    diagnostic_sink: RefCell::new(None),
113                    _culture_subscription: subscription,
114                }
115            },
116        ))
117    }
118    pub fn cultures(&self) -> CultureService {
119        self.0.cultures.clone()
120    }
121    pub fn current_culture(&self) -> Culture {
122        self.0.cultures.current_culture()
123    }
124    pub fn current_ui_culture(&self) -> Culture {
125        self.0.cultures.current_ui_culture()
126    }
127    pub fn options(&self) -> TranslationFallbackOptions {
128        self.0.options.borrow().clone()
129    }
130    pub fn set_options(&self, options: TranslationFallbackOptions) {
131        if *self.0.options.borrow() == options {
132            return;
133        }
134        *self.0.options.borrow_mut() = options;
135        self.refresh();
136    }
137    pub fn set_cache_options(&self, options: TranslationCacheOptions) {
138        *self.0.cache_options.borrow_mut() = options;
139        self.clear_cache();
140    }
141    pub fn set_diagnostic_sink(&self, sink: Option<Rc<dyn ProTranslateDiagnosticSink>>) {
142        *self.0.diagnostic_sink.borrow_mut() = sink;
143    }
144    pub fn subscribe(&self, callback: impl Fn(&CultureChangedEventArgs) + 'static) -> Subscription {
145        self.0.changed.subscribe(callback)
146    }
147    pub fn subscribe_diagnostics(
148        &self,
149        callback: impl Fn(&ProTranslateDiagnostic) + 'static,
150    ) -> Subscription {
151        self.0.diagnostics.subscribe(callback)
152    }
153    pub fn subscriber_count(&self) -> usize {
154        self.0.changed.listener_count()
155    }
156    pub fn clear_cache(&self) {
157        self.0.cache.borrow_mut().clear();
158    }
159    /// Explicitly announces mutable provider data or changed configuration.
160    /// This also refreshes observable translations without changing culture.
161    pub fn refresh(&self) {
162        self.0
163            .generation
164            .set(self.0.generation.get().wrapping_add(1));
165        self.clear_cache();
166        let snapshot = self.0.cultures.snapshot();
167        self.0.changed.emit(CultureChangedEventArgs {
168            old: snapshot.clone(),
169            new: snapshot,
170        });
171    }
172    pub fn cached_lookup_count(&self) -> usize {
173        self.0.cache.borrow().values.len()
174    }
175    pub fn remove_cached_string(&self, key: &str, culture: &Culture) -> bool {
176        let cache_key = (culture.clone(), key.into());
177        let mut cache = self.0.cache.borrow_mut();
178        // A key may still be resolving, so invalidation matters even when it
179        // has not yet been inserted into the cache.
180        cache.epoch = cache.epoch.wrapping_add(1);
181        let removed = cache.values.remove(&cache_key).is_some();
182        if removed {
183            cache.order.retain(|key| key != &cache_key);
184        }
185        removed
186    }
187    pub fn fallback_cultures(&self, culture: &Culture) -> Vec<Culture> {
188        let options = self.options();
189        Self::fallback_cultures_with(culture, &options)
190    }
191    fn fallback_cultures_with(
192        culture: &Culture,
193        options: &TranslationFallbackOptions,
194    ) -> Vec<Culture> {
195        let mut seen = HashSet::new();
196        let mut result = Vec::new();
197        let mut append = |culture: &Culture| {
198            let mut current = Some(culture.clone());
199            while let Some(culture) = current {
200                if seen.insert(culture.clone()) {
201                    result.push(culture.clone());
202                }
203                current = if options.use_parent_cultures {
204                    culture.parent()
205                } else {
206                    None
207                };
208            }
209        };
210        append(culture);
211        for fallback in &options.fallback_cultures {
212            append(fallback);
213        }
214        if options.use_default_culture {
215            append(&options.default_culture);
216        }
217        result
218    }
219    pub fn get_string(&self, key: &str) -> Result<LocalizedString, TranslationError> {
220        self.get_string_for(key, &self.current_ui_culture())
221    }
222    pub fn get_string_for(
223        &self,
224        key: &str,
225        culture: &Culture,
226    ) -> Result<LocalizedString, TranslationError> {
227        validate_translation_key(key)?;
228        let cache_options = self.0.cache_options.borrow().clone();
229        let cache_key = (culture.clone(), key.into());
230        let provider_revision = self.0.provider.revision();
231        let generation = self.0.generation.get();
232        if cache_options.enabled {
233            let mut cache = self.0.cache.borrow_mut();
234            if cache.provider_revision != provider_revision {
235                cache.clear();
236                cache.provider_revision = provider_revision;
237            }
238            if let Some(value) = cache.values.get(&cache_key) {
239                return Ok(value.clone());
240            }
241        }
242        let cache_epoch = self.0.cache.borrow().epoch;
243        let options = self.options();
244        let mut all_diagnostics = Vec::new();
245        let mut result = None;
246        for candidate in Self::fallback_cultures_with(culture, &options) {
247            match self.0.provider.get_string(key, &candidate) {
248                Ok(mut localized) => {
249                    for diagnostic in &localized.diagnostics {
250                        self.report(diagnostic.clone());
251                    }
252                    all_diagnostics.append(&mut localized.diagnostics);
253                    if !localized.resource_not_found {
254                        localized.diagnostics = std::mem::take(&mut all_diagnostics);
255                        result = Some(localized);
256                        break;
257                    }
258                }
259                Err(error) => {
260                    let diagnostic = ProTranslateDiagnostic::provider(
261                        key,
262                        &candidate,
263                        self.0.provider.name(),
264                        error.clone(),
265                    );
266                    self.report(diagnostic.clone());
267                    all_diagnostics.push(diagnostic);
268                    if options.provider_failure_behavior
269                        == TranslationProviderFailureBehavior::ReportAndReturnError
270                    {
271                        return Err(error);
272                    }
273                }
274            }
275        }
276        let resolved = result.unwrap_or_else(|| {
277            let diagnostic = ProTranslateDiagnostic {
278                kind: ProTranslateDiagnosticKind::MissingTranslation,
279                severity: ProTranslateDiagnosticSeverity::Warning,
280                message: format!("Translation '{key}' was not found for culture '{culture}'"),
281                key: Some(key.into()),
282                culture: Some(culture.clone()),
283                provider_name: Some(self.0.provider.name().into()),
284                error: None,
285            };
286            self.report(diagnostic.clone());
287            all_diagnostics.push(diagnostic);
288            let mut localized =
289                LocalizedString::missing(key, culture.clone(), self.0.provider.name());
290            if !options.return_key_when_missing {
291                localized.value.clear();
292            }
293            localized.diagnostics = all_diagnostics;
294            localized
295        });
296        if cache_options.enabled
297            && (!resolved.resource_not_found || cache_options.cache_missing_translations)
298        {
299            // A callback may mutate provider data or policy while diagnostics run;
300            // do not publish a stale resolution into the new cache generation.
301            if self.0.provider.revision() == provider_revision
302                && self.options() == options
303                && self.0.generation.get() == generation
304                && *self.0.cache_options.borrow() == cache_options
305            {
306                let mut cache = self.0.cache.borrow_mut();
307                if cache.epoch != cache_epoch {
308                    return Ok(resolved);
309                }
310                let is_new = !cache.values.contains_key(&cache_key);
311                if is_new && cache_options.maximum_entries > 0 {
312                    while cache.values.len() >= cache_options.maximum_entries {
313                        if let Some(old) = cache.order.pop_front() {
314                            cache.values.remove(&old);
315                        } else {
316                            break;
317                        }
318                    }
319                }
320                if is_new {
321                    cache.order.push_back(cache_key.clone());
322                }
323                cache.values.insert(cache_key, resolved.clone());
324            }
325        }
326        Ok(resolved)
327    }
328    pub fn format(
329        &self,
330        key: &str,
331        arguments: &[FormatArgument],
332    ) -> Result<String, TranslationError> {
333        let snapshot = self.0.cultures.snapshot();
334        let localized = self.get_string_for(key, &snapshot.ui_culture)?;
335        if arguments.is_empty() {
336            return Ok(localized.value);
337        }
338        self.format_localized(localized, arguments, &snapshot.culture)
339    }
340    pub fn format_for(
341        &self,
342        key: &str,
343        arguments: &[FormatArgument],
344        culture: &Culture,
345    ) -> Result<String, TranslationError> {
346        let localized = self.get_string_for(key, culture)?;
347        if arguments.is_empty() {
348            return Ok(localized.value);
349        }
350        self.format_localized(localized, arguments, culture)
351    }
352    pub fn format_localized(
353        &self,
354        localized: LocalizedString,
355        arguments: &[FormatArgument],
356        culture: &Culture,
357    ) -> Result<String, TranslationError> {
358        let failure_behavior = self.options().format_failure_behavior;
359        match format_composite(&localized.value, arguments, culture) {
360            Ok(value) => Ok(value),
361            Err(error) => {
362                self.report(ProTranslateDiagnostic {
363                    kind: ProTranslateDiagnosticKind::FormatFailure,
364                    severity: ProTranslateDiagnosticSeverity::Error,
365                    message: format!(
366                        "Localized format '{}' failed for '{}': {error}",
367                        localized.key, culture
368                    ),
369                    key: Some(localized.key),
370                    culture: Some(culture.clone()),
371                    provider_name: Some(localized.provider_name),
372                    error: Some(error.clone()),
373                });
374                if failure_behavior == TranslationFormatFailureBehavior::ReportAndReturnError {
375                    Err(error)
376                } else {
377                    Ok(localized.value)
378                }
379            }
380        }
381    }
382    /// Format an adapter-provided fallback or outer format with the configured
383    /// failure policy and diagnostic sink.
384    pub fn format_value(
385        &self,
386        key: &str,
387        template: &str,
388        arguments: &[FormatArgument],
389        culture: &Culture,
390    ) -> Result<String, TranslationError> {
391        self.format_localized(
392            LocalizedString::found(key, template, culture.clone(), self.0.provider.name()),
393            arguments,
394            culture,
395        )
396    }
397    pub fn observe(
398        &self,
399        key: impl Into<String>,
400        arguments: Vec<FormatArgument>,
401    ) -> ObservableLocalizedString {
402        self.try_observe(key, arguments)
403            .expect("a localized observation must have a nonempty key")
404    }
405    pub fn try_observe(
406        &self,
407        key: impl Into<String>,
408        arguments: Vec<FormatArgument>,
409    ) -> Result<ObservableLocalizedString, TranslationError> {
410        let key = key.into();
411        validate_translation_key(&key)?;
412        Ok(ObservableLocalizedString::new(self.clone(), key, arguments))
413    }
414    pub(crate) fn report(&self, diagnostic: ProTranslateDiagnostic) {
415        let sink = self.0.diagnostic_sink.borrow().clone();
416        if let Some(sink) = sink {
417            sink.report(&diagnostic);
418        }
419        self.0.diagnostics.emit(diagnostic);
420    }
421}
422/// One coherent observation, including the resolved resource metadata and the
423/// independent UI and formatting cultures used to produce its display value.
424#[derive(Clone, Debug, PartialEq, Eq)]
425pub struct ObservableLocalizedSnapshot {
426    pub value: Result<String, TranslationError>,
427    pub localized_string: Result<LocalizedString, TranslationError>,
428    pub cultures: CultureSnapshot,
429}
430struct ObservableInner {
431    generation: Cell<u64>,
432    service: TranslationService,
433    key: String,
434    arguments: RefCell<Vec<FormatArgument>>,
435    snapshot: RefCell<ObservableLocalizedSnapshot>,
436    changed: Event<ObservableLocalizedSnapshot>,
437    subscription: RefCell<Option<Subscription>>,
438}
439#[derive(Clone)]
440pub struct ObservableLocalizedString(Rc<ObservableInner>);
441impl ObservableLocalizedString {
442    fn new(service: TranslationService, key: String, arguments: Vec<FormatArgument>) -> Self {
443        let snapshot = ObservableLocalizedSnapshot {
444            value: Err(TranslationError::InvalidKey),
445            localized_string: Err(TranslationError::InvalidKey),
446            cultures: service.cultures().snapshot(),
447        };
448        let observable = Self(Rc::new_cyclic(|weak: &std::rc::Weak<ObservableInner>| {
449            let weak = weak.clone();
450            let subscription = service.subscribe(move |_| {
451                if let Some(inner) = weak.upgrade() {
452                    Self(inner).refresh();
453                }
454            });
455            ObservableInner {
456                generation: Cell::new(0),
457                service,
458                key,
459                arguments: RefCell::new(arguments),
460                snapshot: RefCell::new(snapshot),
461                changed: Event::default(),
462                subscription: RefCell::new(Some(subscription)),
463            }
464        }));
465        observable.refresh();
466        observable
467    }
468    pub fn key(&self) -> &str {
469        &self.0.key
470    }
471    pub fn value(&self) -> Result<String, TranslationError> {
472        self.0.snapshot.borrow().value.clone()
473    }
474    pub fn snapshot(&self) -> ObservableLocalizedSnapshot {
475        self.0.snapshot.borrow().clone()
476    }
477    pub fn localized_string(&self) -> Result<LocalizedString, TranslationError> {
478        self.0.snapshot.borrow().localized_string.clone()
479    }
480    /// The culture that supplied the resolved resource, which can be a parent
481    /// or fallback culture rather than the currently requested UI culture.
482    pub fn culture(&self) -> Result<Culture, TranslationError> {
483        self.localized_string().map(|localized| localized.culture)
484    }
485    /// Update arguments, preserving the source proxy's notification semantics
486    /// even when the formatted text remains equal.
487    ///
488    /// # Panics
489    /// Panics after disposal. `update_arguments` exposes a recoverable error.
490    pub fn set_arguments(&self, arguments: Vec<FormatArgument>) {
491        self.update_arguments(arguments)
492            .expect("cannot update a disposed localized observation");
493    }
494    pub fn update_arguments(&self, arguments: Vec<FormatArgument>) -> Result<(), TranslationError> {
495        if self.is_disposed() {
496            return Err(TranslationError::Disposed);
497        }
498        *self.0.arguments.borrow_mut() = arguments;
499        self.refresh();
500        Ok(())
501    }
502    /// Disconnect all clones of this observation while retaining its last
503    /// snapshot. Dropping the final clone also disconnects automatically.
504    pub fn dispose(&self) {
505        self.0
506            .generation
507            .set(self.0.generation.get().wrapping_add(1));
508        self.0.subscription.borrow_mut().take();
509    }
510    pub fn is_disposed(&self) -> bool {
511        self.0.subscription.borrow().is_none()
512    }
513    pub fn refresh(&self) {
514        if self.is_disposed() {
515            return;
516        }
517        let observation_generation = self.0.generation.get().wrapping_add(1);
518        self.0.generation.set(observation_generation);
519        let arguments = self.0.arguments.borrow().clone();
520        let generation = self.0.service.0.generation.get();
521        let cultures = self.0.service.cultures().snapshot();
522        let localized_string = self
523            .0
524            .service
525            .get_string_for(&self.0.key, &cultures.ui_culture);
526        let value = localized_string.clone().and_then(|localized| {
527            if arguments.is_empty() {
528                Ok(localized.value)
529            } else {
530                self.0
531                    .service
532                    .format_localized(localized, &arguments, &cultures.culture)
533            }
534        });
535        // Reentrant diagnostics may have requested a refresh or changed culture.
536        // That event is already queued; it will compute the newest value.
537        if self.0.service.0.generation.get() != generation
538            || self.0.generation.get() != observation_generation
539        {
540            return;
541        }
542        let snapshot = ObservableLocalizedSnapshot {
543            value,
544            localized_string,
545            cultures,
546        };
547        *self.0.snapshot.borrow_mut() = snapshot.clone();
548        self.0.changed.emit(snapshot);
549    }
550    pub fn subscribe(
551        &self,
552        callback: impl Fn(&Result<String, TranslationError>) + 'static,
553    ) -> Subscription {
554        self.0
555            .changed
556            .subscribe(move |snapshot| callback(&snapshot.value))
557    }
558    /// Observe metadata and value together. Refreshes notify even if only the
559    /// resolved culture, provider, diagnostics, or formatting culture changed.
560    pub fn subscribe_snapshot(
561        &self,
562        callback: impl Fn(&ObservableLocalizedSnapshot) + 'static,
563    ) -> Subscription {
564        self.0.changed.subscribe(callback)
565    }
566}